Skip to main content
This example demonstrates how to work with arrays, complex state updates, and data persistence using Stan.js’s built-in storage synchronizer.

Features

  • Add, complete, and delete todos
  • Filter todos by status
  • Persist data to localStorage
  • TypeScript-first design

Store Setup with Persistence

Use the storage synchronizer to automatically persist todos to localStorage:
store.ts
import { createStore } from 'stan-js'
import { storage } from 'stan-js/storage'

type Todo = {
    id: string
    text: string
    completed: boolean
    createdAt: number
}

export const { useStore, getState, reset } = createStore({
    // Persisted to localStorage automatically
    todos: storage<Todo[]>([], {
        storageKey: 'my-todos',
    }),
    filter: 'all' as 'all' | 'active' | 'completed',
    // Computed value - filtered todos
    get filteredTodos() {
        if (this.filter === 'all') return this.todos
        if (this.filter === 'active') return this.todos.filter(t => !t.completed)
        return this.todos.filter(t => t.completed)
    },
    // Computed value - count of active todos
    get activeCount() {
        return this.todos.filter(t => !t.completed).length
    },
})
The storage synchronizer:
  • Saves to localStorage on every update
  • Loads persisted data on initialization
  • Syncs across browser tabs automatically
  • Works with SSR (falls back to memory storage)

Todo Operations

Create helper functions for common todo operations:
todoHelpers.ts
import { useStore } from './store'

export const useTodoActions = () => {
    const { setTodos } = useStore()

    const addTodo = (text: string) => {
        setTodos(prev => [
            ...prev,
            {
                id: crypto.randomUUID(),
                text,
                completed: false,
                createdAt: Date.now(),
            },
        ])
    }

    const toggleTodo = (id: string) => {
        setTodos(prev =>
            prev.map(todo =>
                todo.id === id
                    ? { ...todo, completed: !todo.completed }
                    : todo
            )
        )
    }

    const deleteTodo = (id: string) => {
        setTodos(prev => prev.filter(todo => todo.id !== id))
    }

    const clearCompleted = () => {
        setTodos(prev => prev.filter(todo => !todo.completed))
    }

    return { addTodo, toggleTodo, deleteTodo, clearCompleted }
}

Component Implementation

App.tsx
import { useState } from 'react'
import { useStore } from './store'
import { useTodoActions } from './todoHelpers'

export const App = () => {
const [input, setInput] = useState('')
const { filteredTodos, filter, activeCount, setFilter } = useStore()
const { addTodo, toggleTodo, deleteTodo, clearCompleted } = useTodoActions()

const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (input.trim()) {
        addTodo(input.trim())
        setInput('')
    }
}

return (
    <div>
        <h1>Todos</h1>
        
        <form onSubmit={handleSubmit}>
            <input
                value={input}
                onChange={e => setInput(e.target.value)}
                placeholder="What needs to be done?"
            />
            <button type="submit">Add</button>
        </form>

        <div>
            <button
                onClick={() => setFilter('all')}
                disabled={filter === 'all'}
            >
                All
            </button>
            <button
                onClick={() => setFilter('active')}
                disabled={filter === 'active'}
            >
                Active ({activeCount})
            </button>
            <button
                onClick={() => setFilter('completed')}
                disabled={filter === 'completed'}
            >
                Completed
            </button>
        </div>

        <ul>
            {filteredTodos.map(todo => (
                <li key={todo.id}>
                    <input
                        type="checkbox"
                        checked={todo.completed}
                        onChange={() => toggleTodo(todo.id)}
                    />
                    <span
                        style={{
                            textDecoration: todo.completed
                                ? 'line-through'
                                : 'none',
                        }}
                    >
                        {todo.text}
                    </span>
                    <button onClick={() => deleteTodo(todo.id)}>×</button>
                </li>
            ))}
        </ul>

        {filteredTodos.length === 0 && (
            <p>No todos to display</p>
        )}

        {activeCount > 0 && (
            <button onClick={clearCompleted}>Clear completed</button>
        )}
    </div>
)
}

Storage Configuration

Custom Storage Key

By default, Stan.js uses the property name as the storage key. Customize it:
import { storage } from 'stan-js/storage'

const { useStore } = createStore({
    todos: storage<Todo[]>([], {
        storageKey: 'my-app-todos', // Custom key in localStorage
    }),
})

Custom Serialization

Control how data is serialized/deserialized:
import { storage } from 'stan-js/storage'

const { useStore } = createStore({
    todos: storage<Todo[]>([], {
        serialize: (value) => {
            // Custom serialization logic
            return JSON.stringify(value)
        },
        deserialize: (value) => {
            // Custom deserialization logic
            const parsed = JSON.parse(value)
            // Migrate old data format if needed
            return parsed.map(todo => ({
                ...todo,
                createdAt: todo.createdAt ?? Date.now(),
            }))
        },
    }),
})

Working with Arrays

// Add to end
setTodos(prev => [...prev, newTodo])

// Add to beginning
setTodos(prev => [newTodo, ...prev])

// Insert at specific index
setTodos(prev => [
    ...prev.slice(0, index),
    newTodo,
    ...prev.slice(index),
])
// Update by ID
setTodos(prev =>
    prev.map(todo =>
        todo.id === targetId
            ? { ...todo, completed: !todo.completed }
            : todo
    )
)

// Update by index
setTodos(prev =>
    prev.map((todo, idx) =>
        idx === targetIndex
            ? { ...todo, text: newText }
            : todo
    )
)
// Remove by ID
setTodos(prev => prev.filter(todo => todo.id !== targetId))

// Remove by index
setTodos(prev => prev.filter((_, idx) => idx !== targetIndex))

// Remove all completed
setTodos(prev => prev.filter(todo => !todo.completed))

// Clear all
setTodos([])
// Sort by date
setTodos(prev => [...prev].sort((a, b) => b.createdAt - a.createdAt))

// Note: Use computed values for filtering instead of updating state
get activeTodos() {
    return this.todos.filter(t => !t.completed)
}

Cross-Tab Synchronization

The storage synchronizer automatically syncs state across browser tabs:
// Tab 1: Add a todo
addTodo('Learn Stan.js')

// Tab 2: Automatically receives the update!
// No extra code needed - it just works
Cross-tab sync uses the browser’s storage event. Updates happen in real-time across all open tabs of your app.

SSR Compatibility

The storage synchronizer works with Server-Side Rendering:
// During SSR: Uses in-memory storage
// In browser: Uses localStorage
// Hydration: Seamlessly syncs persisted data
During SSR, the initial value is used. After hydration, the persisted value from localStorage is loaded. This may cause a brief flash of initial content.

Next Steps

Async Data

Handle loading states and async operations

React Native

Use MMKV storage for mobile apps

Storage API

Learn about all storage options

Computed Values

Master getters for derived state

Build docs developers (and LLMs) love