TanStack / TanStack/db

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

Open
#357 6 comments 30 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

RFC
Dominant language
TypeScript
Stars
3.9k
Forks
266
Avg merge
1d 4h
Merged PRs (30d)
55

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reviewing TanStack DB's collection system and the proposed createItem and useItem entry points. Trace how the examples would interact with schemas, persistence, actions, queries, and subscriptions, then determine the API scope and acceptance criteria needed for a complete implementation.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.