-
Notifications
You must be signed in to change notification settings - Fork 104
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
-
Collection Wrapper: Each item is a wrapper around a single-entry collection with a fixed key.
-
Actions Integration: Actions are bound to the item instance and have access to update/value.
-
Optimistic Updates: Leverages collection's existing optimistic update system.
-
Schema Validation: Uses collection's schema validation for type safety.
-
Persistence: Built on localStorage/sessionStorage collection options.
-
Subscriptions: Uses collection's change subscription system.
-
Query Integration: Since items are collections, they can be referenced in queries.
Benefits
- Unified State Management: One system for all client-side state
- Co-located Logic: State and actions defined together like Zustand
- Type Safety: Full TypeScript support with runtime validation
- Built-in Persistence: Easy localStorage/sessionStorage integration
- Queryable State: UI state can be used in complex queries
- Familiar API: Similar to existing state managers for easy adoption
- 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
- Should items have their own namespace in storage or share with collections?
- Should we support computed/derived items (like Recoil selectors)?
- What's the best way to handle async initialization?
- Should items support middleware/plugins for logging, devtools, etc?
- How should we handle TypeScript inference when schema is provided?
- Should actions be able to return values (for async operations)?
- Should we support action composition or inheritance?
Next Steps
- Implement proof of concept
- Add tests for common patterns
- Create migration guides from popular state managers
- Add DevTools integration
- 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.