Skip to content

Fetch API 🌐

SigPro provides a simple, lightweight wrapper around the native Fetch API that integrates seamlessly with signals for loading state management. It's designed for common use cases with sensible defaults.

Core Concepts

What is $.fetch?

A ultra-simple fetch wrapper that:

  • Automatically handles JSON serialization and parsing
  • Integrates with signals for loading state
  • Returns null on error (no try/catch needed for basic usage)
  • Works great with effects for reactive data fetching

$.fetch(url, data, [loading])

Makes a POST request with JSON data and optional loading signal.

javascript
import { $ } from 'sigpro';

const loading = $(false);

async function loadUser() {
  const user = await $.fetch('/api/user', { id: 123 }, loading);
  if (user) {
    console.log('User loaded:', user);
  }
}

📋 API Reference

Parameters

ParameterTypeDescription
urlstringEndpoint URL
dataObjectData to send (automatically JSON.stringify'd)
loadingFunction (optional)Signal function to track loading state

Returns

ReturnDescription
Promise<Object|null>Parsed JSON response or null on error

🎯 Basic Examples

Simple Data Fetching

javascript
import { $ } from 'sigpro';

const userData = $(null);

async function fetchUser(id) {
  const data = await $.fetch('/api/user', { id });
  if (data) {
    userData(data);
  }
}

fetchUser(123);

With Loading State

javascript
import { $, html } from 'sigpro';

const user = $(null);
const loading = $(false);

async function loadUser(id) {
  const data = await $.fetch('/api/user', { id }, loading);
  if (data) user(data);
}

// In your template
html`
  <div>
    ${() => loading() ? html`
      <div class="spinner">Loading...</div>
    ` : user() ? html`
      <div>
        <h2>${user().name}</h2>
        <p>Email: ${user().email}</p>
      </div>
    ` : html`
      <p>No user found</p>
    `}
  </div>
`;

In an Effect

javascript
import { $ } from 'sigpro';

const userId = $(1);
const user = $(null);
const loading = $(false);

$.effect(() => {
  const id = userId();
  if (id) {
    $.fetch(`/api/users/${id}`, null, loading).then(data => {
      if (data) user(data);
    });
  }
});

userId(2); // Automatically fetches new user

🚀 Advanced Examples

User Profile with Loading States

javascript
import { $, html } from 'sigpro';

const Profile = () => {
  const userId = $(1);
  const user = $(null);
  const loading = $(false);
  const error = $(null);

  const fetchUser = async (id) => {
    error(null);
    const data = await $.fetch('/api/user', { id }, loading);
    if (data) {
      user(data);
    } else {
      error('Failed to load user');
    }
  };

  // Fetch when userId changes
  $.effect(() => {
    fetchUser(userId());
  });

  return html`
    <div class="profile">
      <div class="user-selector">
        <button @click=${() => userId(1)}>User 1</button>
        <button @click=${() => userId(2)}>User 2</button>
        <button @click=${() => userId(3)}>User 3</button>
      </div>

      ${() => {
        if (loading()) {
          return html`<div class="spinner">Loading profile...</div>`;
        }
        
        if (error()) {
          return html`<div class="error">${error()}</div>`;
        }
        
        if (user()) {
          return html`
            <div class="user-info">
              <h2>${user().name}</h2>
              <p>Email: ${user().email}</p>
              <p>Role: ${user().role}</p>
              <p>Joined: ${new Date(user().joined).toLocaleDateString()}</p>
            </div>
          `;
        }
        
        return html`<p>Select a user</p>`;
      }}
    </div>
  `;
};

Todo List with API

javascript
import { $, html } from 'sigpro';

const TodoApp = () => {
  const todos = $([]);
  const loading = $(false);
  const newTodo = $('');
  const filter = $('all'); // 'all', 'active', 'completed'

  // Load todos
  const loadTodos = async () => {
    const data = await $.fetch('/api/todos', {}, loading);
    if (data) todos(data);
  };

  // Add todo
  const addTodo = async () => {
    if (!newTodo().trim()) return;
    
    const todo = await $.fetch('/api/todos', {
      text: newTodo(),
      completed: false
    });
    
    if (todo) {
      todos([...todos(), todo]);
      newTodo('');
    }
  };

  // Toggle todo
  const toggleTodo = async (id, completed) => {
    const updated = await $.fetch(`/api/todos/${id}`, {
      completed: !completed
    });
    
    if (updated) {
      todos(todos().map(t => 
        t.id === id ? updated : t
      ));
    }
  };

  // Delete todo
  const deleteTodo = async (id) => {
    const result = await $.fetch(`/api/todos/${id}/delete`, {});
    if (result) {
      todos(todos().filter(t => t.id !== id));
    }
  };

  // Filtered todos
  const filteredTodos = $(() => {
    const currentFilter = filter();
    if (currentFilter === 'all') return todos();
    if (currentFilter === 'active') {
      return todos().filter(t => !t.completed);
    }
    return todos().filter(t => t.completed);
  });

  // Load on mount
  loadTodos();

  return html`
    <div class="todo-app">
      <h1>Todo List</h1>
      
      <div class="add-todo">
        <input
          type="text"
          :value=${newTodo}
          @keydown.enter=${addTodo}
          placeholder="Add a new todo..."
        />
        <button @click=${addTodo}>Add</button>
      </div>
      
      <div class="filters">
        <button 
          class:active=${() => filter() === 'all'}
          @click=${() => filter('all')}
        >
          All
        </button>
        <button 
          class:active=${() => filter() === 'active'}
          @click=${() => filter('active')}
        >
          Active
        </button>
        <button 
          class:active=${() => filter() === 'completed'}
          @click=${() => filter('completed')}
        >
          Completed
        </button>
      </div>
      
      ${() => loading() ? html`
        <div class="spinner">Loading todos...</div>
      ) : html`
        <ul class="todo-list">
          ${filteredTodos().map(todo => html`
            <li class="todo-item">
              <input
                type="checkbox"
                :checked=${todo.completed}
                @change=${() => toggleTodo(todo.id, todo.completed)}
              />
              <span class:completed=${todo.completed}>${todo.text}</span>
              <button @click=${() => deleteTodo(todo.id)}>🗑️</button>
            </li>
          `)}
        </ul>
      `}
    </div>
  `;
};

Infinite Scroll with Pagination

javascript
import { $, html } from 'sigpro';

const InfiniteScroll = () => {
  const posts = $([]);
  const page = $(1);
  const loading = $(false);
  const hasMore = $(true);
  const error = $(null);

  const loadMore = async () => {
    if (loading() || !hasMore()) return;
    
    const data = await $.fetch('/api/posts', { 
      page: page(),
      limit: 10 
    }, loading);
    
    if (data) {
      if (data.posts.length === 0) {
        hasMore(false);
      } else {
        posts([...posts(), ...data.posts]);
        page(p => p + 1);
      }
    } else {
      error('Failed to load posts');
    }
  };

  // Intersection Observer for infinite scroll
  $.effect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      { threshold: 0.1 }
    );
    
    const sentinel = document.getElementById('sentinel');
    if (sentinel) observer.observe(sentinel);
    
    return () => observer.disconnect();
  });

  // Initial load
  loadMore();

  return html`
    <div class="infinite-scroll">
      <h1>Posts</h1>
      
      <div class="posts">
        ${posts().map(post => html`
          <article class="post">
            <h2>${post.title}</h2>
            <p>${post.body}</p>
            <small>By ${post.author}</small>
          </article>
        `)}
      </div>
      
      <div id="sentinel" class="sentinel">
        ${() => {
          if (loading()) {
            return html`<div class="spinner">Loading more...</div>`;
          }
          if (error()) {
            return html`<div class="error">${error()}</div>`;
          }
          if (!hasMore()) {
            return html`<div class="end">No more posts</div>`;
          }
          return '';
        }}
      </div>
    </div>
  `;
};

Search with Debounce

javascript
import { $, html } from 'sigpro';

const SearchComponent = () => {
  const query = $('');
  const results = $([]);
  const loading = $(false);
  const error = $(null);
  let searchTimeout;

  const performSearch = async (searchQuery) => {
    if (!searchQuery.trim()) {
      results([]);
      return;
    }
    
    const data = await $.fetch('/api/search', { 
      q: searchQuery 
    }, loading);
    
    if (data) {
      results(data);
    } else {
      error('Search failed');
    }
  };

  // Debounced search
  $.effect(() => {
    const searchQuery = query();
    
    clearTimeout(searchTimeout);
    
    if (searchQuery.length < 2) {
      results([]);
      return;
    }
    
    searchTimeout = setTimeout(() => {
      performSearch(searchQuery);
    }, 300);
    
    return () => clearTimeout(searchTimeout);
  });

  return html`
    <div class="search">
      <div class="search-box">
        <input
          type="search"
          :value=${query}
          placeholder="Search..."
          class="search-input"
        />
        ${() => loading() ? html`
          <span class="spinner-small">⌛</span>
        ) : ''}
      </div>
      
      ${() => {
        if (error()) {
          return html`<div class="error">${error()}</div>`;
        }
        
        if (results().length > 0) {
          return html`
            <ul class="results">
              ${results().map(item => html`
                <li class="result-item">
                  <h3>${item.title}</h3>
                  <p>${item.description}</p>
                </li>
              `)}
            </ul>
          `;
        }
        
        if (query().length >= 2 && !loading()) {
          return html`<p class="no-results">No results found</p>`;
        }
        
        return '';
      }}
    </div>
  `;
};

Form Submission

javascript
import { $, html } from 'sigpro';

const ContactForm = () => {
  const formData = $({
    name: '',
    email: '',
    message: ''
  });
  
  const submitting = $(false);
  const submitError = $(null);
  const submitSuccess = $(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    submitError(null);
    submitSuccess(false);
    
    const result = await $.fetch('/api/contact', formData(), submitting);
    
    if (result) {
      submitSuccess(true);
      formData({ name: '', email: '', message: '' });
    } else {
      submitError('Failed to send message. Please try again.');
    }
  };

  const updateField = (field, value) => {
    formData({
      ...formData(),
      [field]: value
    });
  };

  return html`
    <form class="contact-form" @submit=${handleSubmit}>
      <h2>Contact Us</h2>
      
      <div class="form-group">
        <label for="name">Name:</label>
        <input
          type="text"
          id="name"
          :value=${() => formData().name}
          @input=${(e) => updateField('name', e.target.value)}
          required
          ?disabled=${submitting}
        />
      </div>
      
      <div class="form-group">
        <label for="email">Email:</label>
        <input
          type="email"
          id="email"
          :value=${() => formData().email}
          @input=${(e) => updateField('email', e.target.value)}
          required
          ?disabled=${submitting}
        />
      </div>
      
      <div class="form-group">
        <label for="message">Message:</label>
        <textarea
          id="message"
          :value=${() => formData().message}
          @input=${(e) => updateField('message', e.target.value)}
          required
          rows="5"
          ?disabled=${submitting}
        ></textarea>
      </div>
      
      ${() => {
        if (submitting()) {
          return html`<div class="submitting">Sending...</div>`;
        }
        
        if (submitError()) {
          return html`<div class="error">${submitError()}</div>`;
        }
        
        if (submitSuccess()) {
          return html`<div class="success">Message sent successfully!</div>`;
        }
        
        return '';
      }}
      
      <button 
        type="submit" 
        ?disabled=${submitting}
      >
        Send Message
      </button>
    </form>
  `;
};

Real-time Dashboard with Multiple Endpoints

javascript
import { $, html } from 'sigpro';

const Dashboard = () => {
  // Multiple data streams
  const metrics = $({});
  const alerts = $([]);
  const logs = $([]);
  
  const loading = $({
    metrics: false,
    alerts: false,
    logs: false
  });

  const refreshInterval = $(5000); // 5 seconds

  const fetchMetrics = async () => {
    const data = await $.fetch('/api/metrics', {}, loading().metrics);
    if (data) metrics(data);
  };

  const fetchAlerts = async () => {
    const data = await $.fetch('/api/alerts', {}, loading().alerts);
    if (data) alerts(data);
  };

  const fetchLogs = async () => {
    const data = await $.fetch('/api/logs', { 
      limit: 50 
    }, loading().logs);
    if (data) logs(data);
  };

  // Auto-refresh all data
  $.effect(() => {
    fetchMetrics();
    fetchAlerts();
    fetchLogs();
    
    const interval = setInterval(() => {
      fetchMetrics();
      fetchAlerts();
    }, refreshInterval());
    
    return () => clearInterval(interval);
  });

  return html`
    <div class="dashboard">
      <header>
        <h1>System Dashboard</h1>
        <div class="refresh-control">
          <label>
            Refresh interval:
            <select :value=${refreshInterval} @change=${(e) => refreshInterval(parseInt(e.target.value))}>
              <option value="2000">2 seconds</option>
              <option value="5000">5 seconds</option>
              <option value="10000">10 seconds</option>
              <option value="30000">30 seconds</option>
            </select>
          </label>
        </div>
      </header>
      
      <div class="dashboard-grid">
        <!-- Metrics Panel -->
        <div class="panel metrics">
          <h2>System Metrics</h2>
          ${() => loading().metrics ? html`
            <div class="spinner">Loading metrics...</div>
          ) : html`
            <div class="metrics-grid">
              <div class="metric">
                <label>CPU</label>
                <span>${metrics().cpu || 0}%</span>
              </div>
              <div class="metric">
                <label>Memory</label>
                <span>${metrics().memory || 0}%</span>
              </div>
              <div class="metric">
                <label>Requests</label>
                <span>${metrics().requests || 0}/s</span>
              </div>
            </div>
          `}
        </div>
        
        <!-- Alerts Panel -->
        <div class="panel alerts">
          <h2>Active Alerts</h2>
          ${() => loading().alerts ? html`
            <div class="spinner">Loading alerts...</div>
          ) : alerts().length > 0 ? html`
            <ul>
              ${alerts().map(alert => html`
                <li class="alert ${alert.severity}">
                  <strong>${alert.type}</strong>
                  <p>${alert.message}</p>
                  <small>${new Date(alert.timestamp).toLocaleTimeString()}</small>
                </li>
              `)}
            </ul>
          ) : html`
            <p class="no-data">No active alerts</p>
          `}
        </div>
        
        <!-- Logs Panel -->
        <div class="panel logs">
          <h2>Recent Logs</h2>
          ${() => loading().logs ? html`
            <div class="spinner">Loading logs...</div>
          ) : html`
            <ul>
              ${logs().map(log => html`
                <li class="log ${log.level}">
                  <span class="timestamp">${new Date(log.timestamp).toLocaleTimeString()}</span>
                  <span class="message">${log.message}</span>
                </li>
              `)}
            </ul>
          `}
        </div>
      </div>
    </div>
  `;
};

File Upload

javascript
import { $, html } from 'sigpro';

const FileUploader = () => {
  const files = $([]);
  const uploading = $(false);
  const uploadProgress = $({});
  const uploadResults = $([]);

  const handleFileSelect = (e) => {
    files([...e.target.files]);
  };

  const uploadFiles = async () => {
    if (files().length === 0) return;
    
    uploading(true);
    uploadResults([]);
    
    for (const file of files()) {
      const formData = new FormData();
      formData.append('file', file);
      
      // Track progress for this file
      uploadProgress({
        ...uploadProgress(),
        [file.name]: 0
      });
      
      try {
        // Custom fetch for FormData
        const response = await fetch('/api/upload', {
          method: 'POST',
          body: formData
        });
        
        const result = await response.json();
        
        uploadResults([
          ...uploadResults(),
          { file: file.name, success: true, result }
        ]);
      } catch (error) {
        uploadResults([
          ...uploadResults(),
          { file: file.name, success: false, error: error.message }
        ]);
      }
      
      uploadProgress({
        ...uploadProgress(),
        [file.name]: 100
      });
    }
    
    uploading(false);
  };

  return html`
    <div class="file-uploader">
      <h2>Upload Files</h2>
      
      <input
        type="file"
        multiple
        @change=${handleFileSelect}
        ?disabled=${uploading}
      />
      
      ${() => files().length > 0 ? html`
        <div class="file-list">
          <h3>Selected Files:</h3>
          <ul>
            ${files().map(file => html`
              <li>
                ${file.name} (${(file.size / 1024).toFixed(2)} KB)
                ${() => uploadProgress()[file.name] ? html`
                  <progress value="${uploadProgress()[file.name]}" max="100"></progress>
                ) : ''}
              </li>
            `)}
          </ul>
          
          <button 
            @click=${uploadFiles}
            ?disabled=${uploading}
          >
            ${() => uploading() ? 'Uploading...' : 'Upload Files'}
          </button>
        </div>
      ` : ''}
      
      ${() => uploadResults().length > 0 ? html`
        <div class="upload-results">
          <h3>Upload Results:</h3>
          <ul>
            ${uploadResults().map(result => html`
              <li class="${result.success ? 'success' : 'error'}">
                ${result.file}: 
                ${result.success ? 'Uploaded successfully' : `Failed: ${result.error}`}
              </li>
            `)}
          </ul>
        </div>
      ` : ''}
    </div>
  `;
};

Retry Logic

javascript
import { $ } from 'sigpro';

// Enhanced fetch with retry
const fetchWithRetry = async (url, data, loading, maxRetries = 3) => {
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      if (loading) loading(true);
      
      const result = await $.fetch(url, data);
      if (result !== null) {
        return result;
      }
      
      // If we get null but no error, wait and retry
      if (attempt < maxRetries) {
        await new Promise(resolve => 
          setTimeout(resolve, Math.pow(2, attempt) * 1000) // Exponential backoff
        );
      }
    } catch (error) {
      lastError = error;
      console.warn(`Attempt ${attempt} failed:`, error);
      
      if (attempt < maxRetries) {
        await new Promise(resolve => 
          setTimeout(resolve, Math.pow(2, attempt) * 1000)
        );
      }
    } finally {
      if (attempt === maxRetries && loading) {
        loading(false);
      }
    }
  }
  
  console.error('All retry attempts failed:', lastError);
  return null;
};

// Usage
const loading = $(false);
const data = await fetchWithRetry('/api/unreliable-endpoint', {}, loading, 5);

🎯 Best Practices

1. Always Handle Null Responses

javascript
// ❌ Don't assume success
const data = await $.fetch('/api/data');
console.log(data.property); // Might throw if data is null

// ✅ Check for null
const data = await $.fetch('/api/data');
if (data) {
  console.log(data.property);
} else {
  showError('Failed to load data');
}

2. Use with Effects for Reactivity

javascript
// ❌ Manual fetching
button.addEventListener('click', async () => {
  const data = await $.fetch('/api/data');
  updateUI(data);
});

// ✅ Reactive fetching
const trigger = $(false);

$.effect(() => {
  if (trigger()) {
    $.fetch('/api/data').then(data => {
      if (data) updateUI(data);
    });
  }
});

trigger(true); // Triggers fetch

3. Combine with Loading Signals

javascript
// ✅ Always show loading state
const loading = $(false);
const data = $(null);

async function load() {
  const result = await $.fetch('/api/data', {}, loading);
  if (result) data(result);
}

// In template
html`
  <div>
    ${() => loading() ? '<Spinner />' : 
      data() ? '<Data />' : 
      '<Empty />'}
  </div>
`;

4. Cancel In-flight Requests

javascript
// ✅ Use AbortController with effects
let controller;

$.effect(() => {
  if (controller) {
    controller.abort();
  }
  
  controller = new AbortController();
  
  fetch(url, { signal: controller.signal })
    .then(res => res.json())
    .then(data => {
      if (!controller.signal.aborted) {
        updateData(data);
      }
    });
  
  return () => controller.abort();
});

📊 Error Handling

Basic Error Handling

javascript
const data = await $.fetch('/api/data');
if (!data) {
  // Handle error (show message, retry, etc.)
}

With Error Signal

javascript
const data = $(null);
const error = $(null);
const loading = $(false);

async function loadData() {
  error(null);
  const result = await $.fetch('/api/data', {}, loading);
  
  if (result) {
    data(result);
  } else {
    error('Failed to load data');
  }
}

Pro Tip: Combine $.fetch with $.effect and loading signals for a complete reactive data fetching solution. The loading signal integration makes it trivial to show loading states in your UI.