Skip to main content
The Fetch plugin provides a simple way to sync observables with REST APIs using the browser’s Fetch API. It’s ideal for basic GET/POST operations.

Installation

npm install @legendapp/state

Usage

import { syncedFetch } from '@legendapp/state/sync-plugins/fetch'

const user$ = syncedFetch({
  get: '/api/user',
  set: '/api/user',
  initial: { name: '', email: '' },
  persist: {
    name: 'user',
    plugin: ObservablePersistLocalStorage
  }
})

Parameters

get
Selector<string>
required
URL or function returning URL for GET requests
// Static URL
get: '/api/data'

// Dynamic URL
get: () => `/api/users/${userId$.get()}`

// Observable URL
get: computed(() => `/api/users/${userId$.get()}`)
set
Selector<string>
URL or function returning URL for POST requests
set: '/api/data'

set: () => `/api/users/${userId$.get()}`
getInit
RequestInit
Options for GET fetch requests
getInit: {
  headers: {
    'Authorization': `Bearer ${token}`
  }
}
setInit
RequestInit
Options for POST fetch requests (method defaults to POST)
setInit: {
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`
  }
}
valueType
'arrayBuffer' | 'blob' | 'formData' | 'json' | 'text'
default:"json"
How to parse GET response
valueType: 'json'  // response.json()
valueType: 'text'  // response.text()
valueType: 'blob'  // response.blob()
onSavedValueType
'arrayBuffer' | 'blob' | 'formData' | 'json' | 'text'
default:"json"
How to parse POST response (defaults to valueType)
onSaved
(params: SyncedFetchOnSavedParams) => Partial<TLocal> | void
Called after successful save with server response
onSaved: ({ saved, input, currentValue }) => {
  // Update with server response
  return {
    id: saved.id,
    updatedAt: saved.updatedAt
  }
}
transform
SyncTransform<TLocal, TRemote>
Transform data between local and remote formats
initial
TLocal
Initial value before data loads
persist
PersistOptions
Local persistence configuration

Return Value

Returns a Synced<TLocal> observable.

Examples

Basic GET

import { syncedFetch } from '@legendapp/state/sync-plugins/fetch'

const data$ = syncedFetch({
  get: '/api/data',
  initial: { items: [] }
})

// Automatically fetches on first access
console.log(data$.get())

GET with POST

const user$ = syncedFetch({
  get: '/api/user',
  set: '/api/user',
  initial: { name: '', email: '' }
})

// GET from /api/user
const user = user$.get()

// POST to /api/user when changed
user$.name.set('John')

Dynamic URLs

import { observable } from '@legendapp/state'
import { syncedFetch } from '@legendapp/state/sync-plugins/fetch'

const userId$ = observable('123')

const user$ = syncedFetch({
  get: () => `/api/users/${userId$.get()}`,
  set: () => `/api/users/${userId$.get()}`
})

// Fetches /api/users/123
user$.get()

// Switch user - automatically refetches
userId$.set('456')

With Authentication

const token$ = observable<string | null>(null)

const profile$ = syncedFetch({
  get: '/api/profile',
  set: '/api/profile',
  getInit: computed(() => ({
    headers: {
      'Authorization': `Bearer ${token$.get()}`
    }
  })),
  setInit: computed(() => ({
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token$.get()}`
    }
  }))
})

With Transform

const data$ = syncedFetch({
  get: '/api/data',
  set: '/api/data',
  transform: {
    load: (remote) => ({
      ...remote,
      createdAt: new Date(remote.createdAt)
    }),
    save: (local) => ({
      ...local,
      createdAt: local.createdAt.toISOString()
    })
  }
})

Handle Server Response

const post$ = syncedFetch({
  get: '/api/posts/123',
  set: '/api/posts/123',
  initial: { title: '', content: '', updatedAt: null },
  onSaved: ({ saved }) => {
    // Update with server timestamp
    return {
      updatedAt: saved.updatedAt
    }
  }
})

post$.title.set('New title')
// POST to server
// Server responds with { title: 'New title', updatedAt: '2024-03-01T10:00:00Z' }
// updatedAt is merged into observable

Different Response Types

// Text response
const markdown$ = syncedFetch({
  get: '/api/docs/readme.md',
  valueType: 'text'
})

// Binary response
const image$ = syncedFetch({
  get: '/api/images/photo.jpg',
  valueType: 'blob'
})

// Form data
const form$ = syncedFetch({
  get: '/api/form',
  valueType: 'formData'
})

With Persistence

import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'

const settings$ = syncedFetch({
  get: '/api/settings',
  set: '/api/settings',
  persist: {
    name: 'settings',
    plugin: ObservablePersistLocalStorage
  }
})

// 1. Loads from localStorage (instant)
// 2. Syncs with /api/settings in background
// 3. Saves to both localStorage and server on changes

Error Handling

const data$ = syncedFetch({
  get: '/api/data',
  set: '/api/data',
  onError: (error, { source, type, retry }) => {
    if (source === 'get') {
      console.error('Failed to load:', error)
      showNotification('Failed to load data')
    } else if (source === 'set') {
      console.error('Failed to save:', error)
      showNotification('Failed to save changes')
    }
  }
})

Custom HTTP Methods

// PUT instead of POST
const resource$ = syncedFetch({
  get: '/api/resource/123',
  set: '/api/resource/123',
  setInit: {
    method: 'PUT'
  }
})

// PATCH for partial updates
const user$ = syncedFetch({
  get: '/api/user',
  set: '/api/user',
  setInit: {
    method: 'PATCH'
  }
})

Query Parameters

const filter$ = observable('active')

const todos$ = syncedFetch({
  get: () => {
    const filter = filter$.get()
    return `/api/todos?filter=${filter}`
  }
})

// Fetches /api/todos?filter=active
todos$.get()

// Refetches with new filter
filter$.set('completed')

File Upload

const upload$ = syncedFetch({
  set: '/api/upload',
  setInit: {
    method: 'POST'
  }
})

// Upload file
const formData = new FormData()
formData.append('file', file)

upload$.set(formData)

Advanced Usage

Conditional Fetching

const isLoggedIn$ = observable(false)

const userData$ = syncedFetch({
  get: '/api/user/data',
  waitFor: () => isLoggedIn$.get()
})

// Only fetches when logged in
isLoggedIn$.set(true)

Retry Configuration

const data$ = syncedFetch({
  get: '/api/data',
  set: '/api/data',
  retry: {
    infinite: true,
    delay: 1000,
    backoff: 'exponential',
    maxDelay: 30000
  }
})

Manual Sync

import { syncState } from '@legendapp/state/sync'

const data$ = syncedFetch({
  get: '/api/data',
  syncMode: 'manual'
})

// Manually trigger sync
const state = syncState(data$)
await state.sync()

Best Practices

  1. Use for simple APIs: Best for straightforward REST endpoints
  2. Add authentication: Include auth headers in getInit/setInit
  3. Handle errors: Implement onError for production apps
  4. Use persistence: Add local caching for better UX
  5. Consider alternatives: Use specialized plugins (Supabase, Firebase) for those services

When to Use

Use syncedFetch when:
  • Working with simple REST APIs
  • Need basic GET/POST operations
  • Don’t need real-time updates
  • Want minimal configuration
Use other plugins when:
  • Supabase/Firebase: For those specific services
  • TanStack Query: For complex caching/invalidation
  • Custom synced(): For WebSocket/SSE or custom protocols

See Also

Build docs developers (and LLMs) love