Skip to content

RFC: Simplified Single-Value State Management API for TanStack DB #357

@KyleAMathews

Description

@KyleAMathews

Summary

Introduce a slimmed-down API built on top of TanStack DB's collection system that can replace useState, Redux, Zustand, and other state management solutions. Each "item" would be stored as a single value inside a regular collection, making it queryable alongside other collections while providing a drastically simplified API for common state management patterns.

Motivation

While TanStack DB's collections are powerful for managing sets of data, many applications also need simple state management for individual values (UI state, user preferences, app config, etc.). Currently, developers need to use separate solutions like Redux or Zustand for this, creating fragmentation in their state management approach.

By providing a simplified "item" API that builds on collections, we can:

  • Unify all client-side state under one system
  • Enable querying UI state alongside data collections
  • Leverage existing features (validation, persistence, subscriptions)
  • Provide an easy migration path from existing state managers

Usage Examples

1. Simple Counter (Replacing useState)

// Before (React useState)
function Counter() {
  const [count, setCount] = useState(0)
  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  )
}

// After (TanStack DB Item)
function Counter() {
  const count = useItem({
    initialData: 0
  })
  
  return (
    <button onClick={() => count.update(draft => draft + 1)}>
      Count: {count.value}
    </button>
  )
}

2. Counter with Actions (Zustand-style)

// Before (Zustand)
const useCounter = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 })
}))

// After (TanStack DB Item with actions)
const counterItem = createItem({
  initialData: { count: 0 },
  actions: (item) => ({
    increment: () => item.update(draft => { draft.count++ }),
    decrement: () => item.update(draft => { draft.count-- }),
    reset: () => item.update(() => ({ count: 0 }))
  })
})

function Counter() {
  const counter = useItem(counterItem)
  
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <span>{counter.value.count}</span>
      <button onClick={counter.increment}>+</button>
      <button onClick={counter.reset}>Reset</button>
    </div>
  )
}

3. Form State with Validation and Actions

// Before (Redux)
const formSlice = createSlice({
  name: 'contactForm',
  initialState: { name: '', email: '', message: '' },
  reducers: {
    updateField: (state, action) => {
      state[action.payload.field] = action.payload.value
    },
    resetForm: (state) => ({ name: '', email: '', message: '' })
  }
})

// After (TanStack DB Item)
import { z } from 'zod'

const contactFormSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  message: z.string().min(10)
})

const contactFormItem = createItem({
  schema: contactFormSchema,
  initialData: { name: '', email: '', message: '' },
  actions: (item) => ({
    updateField: (field: string, value: string) => {
      item.update(draft => { draft[field] = value })
    },
    reset: () => {
      item.update(() => ({ name: '', email: '', message: '' }))
    },
    submit: async () => {
      // Schema validation happens automatically
      await sendMessage(item.value)
      item.update(() => ({ name: '', email: '', message: '' }))
    }
  })
})

function ContactForm() {
  const form = useItem(contactFormItem)
  
  return (
    <form onSubmit={(e) => { e.preventDefault(); form.submit() }}>
      <input 
        value={form.value.name}
        onChange={e => form.updateField('name', e.target.value)}
      />
      {/* ... other fields ... */}
      <button type="submit">Send</button>
      <button type="button" onClick={form.reset}>Clear</button>
    </form>
  )
}

4. Global App State with Actions (Replacing Zustand)

// Before (Zustand)
const useAppStore = create((set, get) => ({
  user: null,
  theme: 'light',
  sidebarOpen: true,
  notifications: [],
  
  setUser: (user) => set({ user }),
  logout: () => set({ user: null }),
  toggleTheme: () => set((state) => ({ 
    theme: state.theme === 'light' ? 'dark' : 'light' 
  })),
  toggleSidebar: () => set((state) => ({ 
    sidebarOpen: \!state.sidebarOpen 
  })),
  addNotification: (notification) => set((state) => ({
    notifications: [...state.notifications, notification]
  })),
  dismissNotification: (id) => set((state) => ({
    notifications: state.notifications.filter(n => n.id \!== id)
  }))
}))

// After (TanStack DB Items with actions)
const appStateItem = createItem({
  schema: appStateSchema,
  initialData: {
    user: null,
    theme: 'light',
    sidebarOpen: true,
    notifications: []
  },
  persist: true,
  id: 'app-state',
  actions: (item) => ({
    setUser: (user) => item.update(draft => { draft.user = user }),
    logout: () => item.update(draft => { draft.user = null }),
    toggleTheme: () => item.update(draft => {
      draft.theme = draft.theme === 'light' ? 'dark' : 'light'
    }),
    toggleSidebar: () => item.update(draft => {
      draft.sidebarOpen = \!draft.sidebarOpen
    }),
    addNotification: (notification) => item.update(draft => {
      draft.notifications.push({ ...notification, id: Date.now() })
    }),
    dismissNotification: (id) => item.update(draft => {
      draft.notifications = draft.notifications.filter(n => n.id \!== id)
    }),
    // Computed getters
    get hasNotifications() {
      return item.value.notifications.length > 0
    }
  })
})

// In components
function App() {
  const app = useItem(appStateItem)
  
  return (
    <div className={app.value.theme}>
      <button onClick={app.toggleTheme}>Toggle Theme</button>
      <button onClick={app.toggleSidebar}></button>
      {app.hasNotifications && <NotificationBadge />}
      {/* ... */}
    </div>
  )
}

5. Shopping Cart with Complex Actions

// Shopping cart with business logic
const cartItem = createItem({
  schema: cartSchema,
  initialData: { items: [], total: 0, discount: 0 },
  persist: localStorageAdapter('shopping-cart'),
  actions: (item) => ({
    addItem: (product) => {
      item.update(draft => {
        const existing = draft.items.find(i => i.id === product.id)
        if (existing) {
          existing.quantity++
        } else {
          draft.items.push({ ...product, quantity: 1 })
        }
        recalculateTotal(draft)
      })
    },
    
    removeItem: (productId) => {
      item.update(draft => {
        draft.items = draft.items.filter(i => i.id \!== productId)
        recalculateTotal(draft)
      })
    },
    
    updateQuantity: (productId, quantity) => {
      item.update(draft => {
        const item = draft.items.find(i => i.id === productId)
        if (item) {
          if (quantity <= 0) {
            draft.items = draft.items.filter(i => i.id \!== productId)
          } else {
            item.quantity = quantity
          }
          recalculateTotal(draft)
        }
      })
    },
    
    applyDiscount: (code) => {
      item.update(draft => {
        // Validate discount code
        const discount = validateDiscountCode(code)
        if (discount) {
          draft.discount = discount
          recalculateTotal(draft)
        }
      })
    },
    
    checkout: async () => {
      const order = await processCheckout(item.value)
      item.update(() => ({ items: [], total: 0, discount: 0 }))
      return order
    },
    
    // Computed properties
    get itemCount() {
      return item.value.items.reduce((sum, i) => sum + i.quantity, 0)
    },
    
    get subtotal() {
      return item.value.items.reduce((sum, i) => sum + (i.price * i.quantity), 0)
    }
  })
})

// Helper function used in actions
function recalculateTotal(cart) {
  const subtotal = cart.items.reduce((sum, item) => 
    sum + (item.price * item.quantity), 0
  )
  cart.total = subtotal - (subtotal * cart.discount)
}

// Usage in component
function ShoppingCart() {
  const cart = useItem(cartItem)
  
  return (
    <div>
      <h2>Cart ({cart.itemCount} items)</h2>
      {cart.value.items.map(item => (
        <CartItem 
          key={item.id} 
          {...item} 
          onQuantityChange={(q) => cart.updateQuantity(item.id, q)}
          onRemove={() => cart.removeItem(item.id)}
        />
      ))}
      <div>Subtotal: ${cart.subtotal}</div>
      {cart.value.discount > 0 && <div>Discount: {cart.value.discount * 100}%</div>}
      <div>Total: ${cart.value.total}</div>
      <button onClick={cart.checkout}>Checkout</button>
    </div>
  )
}

6. Async Actions with Loading States

// User profile with async operations
const userProfileItem = createItem({
  schema: userProfileSchema,
  initialData: { 
    profile: null, 
    isLoading: false, 
    error: null 
  },
  actions: (item) => ({
    fetchProfile: async (userId) => {
      item.update(draft => { 
        draft.isLoading = true 
        draft.error = null 
      })
      
      try {
        const profile = await api.getProfile(userId)
        item.update(draft => {
          draft.profile = profile
          draft.isLoading = false
        })
      } catch (error) {
        item.update(draft => {
          draft.error = error.message
          draft.isLoading = false
        })
      }
    },
    
    updateProfile: async (updates) => {
      const optimisticUpdate = item.update(draft => {
        Object.assign(draft.profile, updates)
      })
      
      try {
        const updated = await api.updateProfile(draft.profile.id, updates)
        item.update(draft => { draft.profile = updated })
      } catch (error) {
        // Rollback on error
        optimisticUpdate.rollback()
        throw error
      }
    },
    
    clearProfile: () => {
      item.update(() => ({ 
        profile: null, 
        isLoading: false, 
        error: null 
      }))
    }
  })
})

7. Query Integration with Actions

// Todo filters with actions that affect queries
const todoFiltersItem = createItem({
  schema: z.object({
    showCompleted: z.boolean(),
    sortBy: z.enum(['date', 'priority', 'name']),
    searchTerm: z.string()
  }),
  initialData: { 
    showCompleted: true, 
    sortBy: 'date',
    searchTerm: ''
  },
  persist: true,
  actions: (item) => ({
    toggleCompleted: () => {
      item.update(draft => { 
        draft.showCompleted = \!draft.showCompleted 
      })
    },
    
    setSortBy: (sortBy) => {
      item.update(draft => { draft.sortBy = sortBy })
    },
    
    setSearchTerm: (term) => {
      item.update(draft => { draft.searchTerm = term })
    },
    
    resetFilters: () => {
      item.update(() => ({ 
        showCompleted: true, 
        sortBy: 'date',
        searchTerm: ''
      }))
    }
  })
})

function TodoList() {
  const filters = useItem(todoFiltersItem)
  
  // Build query conditionally based on filters
  function buildTodoQuery() {
    let query = new Query().from({ todos: todoCollection })
    
    // Apply conditional filters
    if (\!filters.value.showCompleted) {
      query = query.where(({ todos }) => eq(todos.completed, false))
    }
    
    if (filters.value.searchTerm) {
      query = query.where(({ todos }) => 
        like(todos.title, `%${filters.value.searchTerm}%`)
      )
    }
    
    // Apply sorting
    query = query.orderBy(({ todos }) => {
      switch(filters.value.sortBy) {
        case 'date': return desc(todos.createdAt)
        case 'priority': return desc(todos.priority)
        case 'name': return asc(todos.name)
      }
    })
    
    return query
  }
  
  const { data: todos } = useLiveQuery(buildTodoQuery)
  
  return (
    <div>
      <FilterBar>
        <input 
          placeholder="Search..."
          value={filters.value.searchTerm}
          onChange={e => filters.setSearchTerm(e.target.value)}
        />
        <button onClick={filters.toggleCompleted}>
          {filters.value.showCompleted ? 'Hide' : 'Show'} Completed
        </button>
        <select 
          value={filters.value.sortBy}
          onChange={e => filters.setSortBy(e.target.value)}
        >
          <option value="date">Date</option>
          <option value="priority">Priority</option>
          <option value="name">Name</option>
        </select>
        <button onClick={filters.resetFilters}>Reset</button>
      </FilterBar>
      <TodoItems todos={todos} />
    </div>
  )
}

Proposed API

Core Functions

// Create a standalone item
function createItem<T, TActions = {}>(config: ItemConfig<T, TActions>): Item<T> & TActions

// React hook for using items
function useItem<T, TActions = {}>(
  configOrItem: ItemConfig<T, TActions> | (Item<T> & TActions)
): ItemHookResult<T> & TActions

// TypeScript interfaces
interface ItemConfig<T, TActions = {}> {
  schema?: StandardSchemaV1
  initialData?: T
  persist?: boolean | StorageAdapter
  id?: string
  actions?: (item: Item<T>) => TActions
}

interface Item<T> {
  value: T
  update: (updater: (draft: T) => void | T) => Transaction
  subscribe: (callback: (value: T) => void) => () => void
  collection: Collection<{ value: T }>  // For advanced use cases
}

interface ItemHookResult<T> extends Item<T> {
  isLoading: boolean
  isError: boolean
}

Storage Adapters

// Built-in adapters
const localStorageAdapter = (key: string): StorageAdapter
const sessionStorageAdapter = (key: string): StorageAdapter
const memoryAdapter = (): StorageAdapter  // Default

// Custom adapter interface
interface StorageAdapter {
  type: 'localStorage' | 'sessionStorage' | 'memory' | 'custom'
  key?: string
  serialize?: (value: any) => string
  deserialize?: (value: string) => any
  storage?: Storage  // For custom storage implementations
}

Implementation Details

  1. Collection Wrapper: Each item is a wrapper around a single-entry collection with a fixed key.

  2. Actions Integration: Actions are bound to the item instance and have access to update/value.

  3. Optimistic Updates: Leverages collection's existing optimistic update system.

  4. Schema Validation: Uses collection's schema validation for type safety.

  5. Persistence: Built on localStorage/sessionStorage collection options.

  6. Subscriptions: Uses collection's change subscription system.

  7. Query Integration: Since items are collections, they can be referenced in queries.

Benefits

  1. Unified State Management: One system for all client-side state
  2. Co-located Logic: State and actions defined together like Zustand
  3. Type Safety: Full TypeScript support with runtime validation
  4. Built-in Persistence: Easy localStorage/sessionStorage integration
  5. Queryable State: UI state can be used in complex queries
  6. Familiar API: Similar to existing state managers for easy adoption
  7. Incremental Adoption: Can be adopted one piece of state at a time

Migration Examples

// From useState
const [user, setUser] = useState(null)
// To
const user = useItem({ initialData: null })

// From Zustand
const bears = useStore(state => state.bears)
const increasePopulation = useStore(state => state.increasePopulation)
// To
const bearsItem = createItem({
  initialData: { bears: 0 },
  actions: (item) => ({
    increasePopulation: () => item.update(draft => { draft.bears++ })
  })
})
const { value: { bears }, increasePopulation } = useItem(bearsItem)

// From Redux Toolkit
const dispatch = useDispatch()
const { status, error } = useSelector(state => state.user)
dispatch(fetchUser(id))
// To
const userItem = createItem({
  initialData: { data: null, status: 'idle', error: null },
  actions: (item) => ({
    fetchUser: async (id) => { /* ... */ }
  })
})
const user = useItem(userItem)
user.fetchUser(id)

Questions for Discussion

  1. Should items have their own namespace in storage or share with collections?
  2. Should we support computed/derived items (like Recoil selectors)?
  3. What's the best way to handle async initialization?
  4. Should items support middleware/plugins for logging, devtools, etc?
  5. How should we handle TypeScript inference when schema is provided?
  6. Should actions be able to return values (for async operations)?
  7. Should we support action composition or inheritance?

Next Steps

  1. Implement proof of concept
  2. Add tests for common patterns
  3. Create migration guides from popular state managers
  4. Add DevTools integration
  5. Performance benchmarks vs existing solutions

This API would make TanStack DB a complete solution for all client-side state management needs while maintaining full compatibility with the existing collection system.

Metadata

Metadata

Assignees

No one assigned

    Labels

    RFCFor posted RFCs

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions