Skip to content

flowcircuits/firestate

Repository files navigation

Firestate

Firestore state management for React with real-time sync, undo/redo, optimistic updates, and optional Standard Schema validation.

npm version License: MIT

Why Firestate?

Managing Firestore state in React applications typically involves:

  • Setting up real-time listeners with proper cleanup
  • Handling optimistic updates and conflict resolution
  • Tracking sync state across multiple documents/collections
  • Implementing undo/redo functionality
  • Lots of boilerplate code that's easy to get wrong

Firestate provides a declarative, schema-first approach that eliminates boilerplate while giving you production-ready features out of the box.

Features

  • TypeScript-first: Plain TS interfaces define your data shape — no validator dependency required
  • Optional validation: Bring your own Standard Schema validator (zod, valibot, arktype, effect, etc.) and use it at your own boundaries
  • Real-time sync: Automatic Firestore listeners with proper lifecycle management
  • Optimistic updates: Changes reflect immediately, sync in background
  • Conflict resolution: Automatic rebasing when concurrent changes occur
  • Undo/redo: Built-in command pattern with action grouping
  • Lazy loading: Collections can defer subscription until needed
  • Diff-based updates: Only changed fields are sent to Firestore

Table of Contents

Installation

pnpm add @hvakr/firestate
# or
npm install @hvakr/firestate
# or
yarn add @hvakr/firestate

Peer Dependencies

Firestate requires the following peer dependencies:

{
  "firebase": "^10.0.0 || ^11.0.0",
  "react": "^18.0.0 || ^19.0.0"
}

Firestate has no runtime validation dependency. If you want to validate at your boundaries, pass any Standard Schema compatible validator (zod 3.24+/4, valibot, arktype, effect schema, etc.) via the optional schema field on a definition.

Quick Start

1. Define your data

// schemas.ts
import { defineDocument, defineCollection } from '@hvakr/firestate'

// Plain TypeScript interfaces — no runtime validator required
interface Project {
    name: string
    description?: string
    createdAt: number
    updatedAt: number
}

interface Space {
    name: string
    area: number
    floor: number
}

// Create a document definition
export const projectDoc = defineDocument<Project>({
    collection: 'projects',
    id: (params) => params.projectId,
    autosave: 1000, // Debounce writes by 1 second
})

// Create a collection definition
export const spacesCollection = defineCollection<Space>({
    path: (params) => `projects/${params.projectId}/spaces`,
    lazy: true, // Only subscribe when load() is called
})

Optional: validating with Standard Schema

If you want runtime validation at your boundaries, pass any Standard Schema compatible validator. Firestate stores it on the definition but never invokes it — you call it where it matters (on submit, in tests, at a server route, etc.):

import { z } from 'zod'
import { defineDocument } from '@hvakr/firestate'

const ProjectSchema = z.object({
    name: z.string(),
    description: z.string().optional(),
    createdAt: z.number(),
    updatedAt: z.number(),
})

// TData is inferred from the schema's output type
export const projectDoc = defineDocument({
    schema: ProjectSchema,
    collection: 'projects',
    id: (params) => params.projectId,
})

// Use the schema at a boundary you control:
const result = projectDoc.schema!['~standard'].validate(unknownInput)

2. Set up the provider

// App.tsx
import { FirestateProvider } from '@hvakr/firestate'
import { db } from './firebase'

function App() {
    return (
        <FirestateProvider
            firestore={db}
            autosave={1000}
            maxUndoLength={20}
        >
            <YourApp />
        </FirestateProvider>
    )
}

3. Use in components

// ProjectEditor.tsx
import { useDocument, useCollection, useUndoManager } from '@hvakr/firestate'
import { projectDoc, spacesCollection } from './schemas'

function ProjectEditor({ projectId }: { projectId: string }) {
    const params = { projectId }

    // Subscribe to project document
    const project = useDocument({ definition: projectDoc, params })

    // Subscribe to spaces collection (lazy)
    const spaces = useCollection({ definition: spacesCollection, params })

    // Access undo/redo
    const { undo, redo, canUndo, canRedo } = useUndoManager()

    if (project.isLoading) return <Spinner />
    if (!project.data) return <NotFound />

    return (
        <div>
            {/* Undo/Redo buttons */}
            <button onClick={undo} disabled={!canUndo}>Undo</button>
            <button onClick={redo} disabled={!canRedo}>Redo</button>

            {/* Edit project name - changes auto-save */}
            <input
                value={project.data.name}
                onChange={(e) => project.update({ name: e.target.value })}
            />

            {/* Lazy-load spaces */}
            {!spaces.isActive ? (
                <button onClick={spaces.load}>Load Spaces</button>
            ) : spaces.isLoading ? (
                <Spinner />
            ) : (
                <ul>
                    {Object.values(spaces.data).map((space) => (
                        <li key={space.id}>
                            {space.name} - {space.area} sq ft
                        </li>
                    ))}
                </ul>
            )}

            {/* Sync indicator */}
            {!project.isSynced && <span>Saving...</span>}
        </div>
    )
}

Examples

Check out the examples directory for complete, runnable examples:

  • React Tasks - A simple task manager demonstrating documents, collections, undo/redo, sync indicators, and real-time updates.

Core Concepts

Documents vs Collections

  • Document: A single Firestore document with a known path
  • Collection: A set of documents, optionally with query constraints

Optimistic Updates

When you call update(), the change is applied immediately to local state. The library then:

  1. Computes the minimal diff
  2. Debounces writes (configurable autosave interval)
  3. Sends only changed fields to Firestore using dot-notation (flattened keys)
  4. Handles any conflicts from concurrent changes

Update vs Set

Firestate uses Firestore's updateDoc for partial updates and setDoc for full replacements:

  • update(diff) - Uses updateDoc with flattened dot-notation keys. This prevents accidentally recreating a document that was deleted by another user. If the document doesn't exist, the update will fail.

  • set(data) - Uses setDoc to create or completely replace a document. Use this when you intentionally want to create a new document or overwrite an existing one.

// Partial update - only changes 'name', fails if document was deleted
project.update({ name: 'New Name' })

// Full replacement - creates document if it doesn't exist
project.set({ name: 'New Project', createdAt: Date.now() })

This distinction is important for collaborative applications where multiple users may be editing simultaneously.

Undo/Redo

Every undoable update automatically creates an undo action. Actions with the same undoGroupId are merged:

const groupId = crypto.randomUUID()

// These two updates become a single undo action
project.update({ name: 'New Name' }, { undoGroupId: groupId })
spaces.update({ space1: { name: 'Updated' } }, { undoGroupId: groupId })

To skip undo tracking:

project.update({ lastViewed: Date.now() }, { undoable: false })

Lazy Collections

For large applications, you may not want to subscribe to every collection immediately:

const spacesCollection = defineCollection({
    schema: SpaceSchema,
    path: (params) => `projects/${params.projectId}/spaces`,
    lazy: true, // Don't subscribe until load() is called
})

// In component
const spaces = useCollection({ definition: spacesCollection, params })
spaces.load() // Start subscription

Sync State Tracking

The library tracks whether all documents/collections are synced:

import { useIsSynced, useUnsavedChangesBlocker } from '@hvakr/firestate'

function App() {
    const isSynced = useIsSynced()
    const shouldBlock = useUnsavedChangesBlocker()

    // Use with react-router's useBlocker
    const blocker = useBlocker(
        ({ currentLocation, nextLocation }) =>
            currentLocation.pathname !== nextLocation.pathname && shouldBlock
    )

    return (
        <>
            {!isSynced && <SavingIndicator />}
            {blocker.state === 'blocked' && (
                <Dialog>Your changes may not be saved!</Dialog>
            )}
        </>
    )
}

Pending edits on unmount

Writes are debounced by autosave (default 1000 ms). If a component unmounts while there are unflushed local edits, those edits are dropped silently — the subscription is gone and the autosave timer is cleared. To handle this:

  • Block navigation with useUnsavedChangesBlocker (shown above) so users can't navigate away while writes are pending.
  • Force a flush by calling handle.sync() before triggering the unmount (e.g., in a custom save-and-close button).
  • Lower autosave if the debounce window is the source of risk.

There is no automatic flush in the subscription's stop() because stop() is synchronous and consumers may unmount during route transitions where awaiting writes is not feasible.

API Reference

Definition Helpers

defineDocument(definition)

Creates a document definition. Provide the document shape via the TData type parameter, or let it be inferred from a Standard Schema validator.

const projectDoc = defineDocument<Project>({
    collection: 'projects',     // Collection path
    id: (params) => params.id,  // Document ID (string or function)
    autosave: 1000,             // Optional: debounce interval (ms)
    minLoadTime: 0,             // Optional: minimum loading time (ms)
    readOnly: false,            // Optional: prevent updates
    retryOnError: false,        // Optional: retry on listener errors
    retryInterval: 5000,        // Optional: retry interval (ms)
    schema: ProjectSchema,      // Optional: Standard Schema validator
})

defineCollection(definition)

Creates a collection definition.

const spacesCollection = defineCollection<Space>({
    path: (params) => `projects/${params.id}/spaces`, // Collection path
    autosave: 1000,                                   // Optional: debounce interval
    minLoadTime: 0,                                   // Optional: minimum loading time
    readOnly: false,                                  // Optional: prevent updates
    lazy: false,                                      // Optional: defer subscription
    queryConstraints: [],                             // Optional: Firestore constraints
    schema: SpaceSchema,                              // Optional: Standard Schema validator
})

React Hooks

useDocument(options)

Subscribe to a document.

const {
    data,           // Current document data (T | undefined)
    update,         // Update with partial diff
    set,            // Replace entire document
    delete: del,    // Delete the document
    isLoading,      // Whether initial data is loading
    isSynced,       // Whether all changes are synced
    sync,           // Force sync immediately
    error,          // Error from listener, if any
    ref,            // Firestore DocumentReference
} = useDocument({
    definition: projectDoc,
    params: { projectId: '123' },
    readOnly: false,     // Optional: override read-only
    undoable: true,      // Optional: enable undo (default: true)
})

useCollection(options)

Subscribe to a collection.

const {
    data,           // Record<string, T> of documents
    update,         // Update one or more documents
    add,            // Add a new document (explicit or auto-generated id)
    remove,         // Remove a document
    isLoading,      // Whether initial data is loading
    isSynced,       // Whether all changes are synced
    isActive,       // Whether subscription is active
    load,           // Activate a lazy subscription
    sync,           // Force sync immediately
    error,          // Error from listener, if any
    ref,            // Firestore CollectionReference
} = useCollection({
    definition: spacesCollection,
    params: { projectId: '123' },
    queryConstraints: [where('floor', '==', 1)],
    undoable: true,
})

// Update existing documents
update({ space1: { name: 'Updated Name' } })

// Add a new document with an explicit id
add('newSpaceId', { name: 'New Space', area: 500, floor: 1 })

// Or let Firestore generate the id — returned synchronously
const id = add({ name: 'New Space', area: 500, floor: 1 })

// Remove a document
remove('oldSpaceId')

useUndoManager()

Access the undo manager.

const {
    canUndo,        // Whether undo is available
    canRedo,        // Whether redo is available
    undo,           // Undo the last action
    redo,           // Redo the last undone action
    clear,          // Clear undo/redo history
    undoStack,      // Array of undo actions
    redoStack,      // Array of redo actions
} = useUndoManager()

useIsSynced()

Check if all tracked resources are synced.

const isSynced = useIsSynced()

useUndoKeyboardShortcuts()

Add Ctrl/Cmd+Z and Ctrl/Cmd+Y keyboard shortcuts.

useUndoKeyboardShortcuts()

Providers

FirestateProvider

Main provider component.

<FirestateProvider
    firestore={db}              // Required: Firestore instance
    autosave={1000}             // Optional: default debounce (ms)
    minLoadTime={0}             // Optional: minimum loading time (ms)
    maxUndoLength={20}          // Optional: max undo stack size
    onError={(error, context) => {
        // Optional: custom error handler
        console.error(context.path, error)
    }}
>
    {children}
</FirestateProvider>

FirestateStoreProvider

Use with a pre-created store for more control.

import { createStore, FirestateStoreProvider } from '@hvakr/firestate'

const store = createStore({ firestore: db })

<FirestateStoreProvider store={store}>
    {children}
</FirestateStoreProvider>

Diff Utilities

Firestate exports a comprehensive set of diff utilities that can be used throughout your application and backend.

Core Diff Operations

import {
    computeDiff,
    applyDiff,
    applyDiffMutable,
    computeUndoDiff,
} from '@hvakr/firestate'

// Compute minimal diff between two objects
const diff = computeDiff(oldState, newState)

// Apply diff (returns new object, original unchanged)
const newState = applyDiff(currentState, diff)

// Apply diff in place (mutates target object) - use for performance-critical paths
applyDiffMutable(targetState, diff)

// Compute the undo diff - what would reverse these changes
const undoDiff = computeUndoDiff(startState, diff)
// Applying undoDiff to the result restores startState

Flattening for Firestore

import { flattenDiff, unflattenDiff } from '@hvakr/firestate'

// Flatten nested diff to dot-notation for Firestore's updateDoc
const nested = { building: { floors: 5, height: 100 } }
const flat = flattenDiff(nested)
// { 'building.floors': 5, 'building.height': 100 }

// Unflatten back to nested structure
const restored = unflattenDiff(flat)
// { building: { floors: 5, height: 100 } }

Path-Based Utilities

import { diffContainsPath, extractDiffValue, createDiffAtPath } from '@hvakr/firestate'

const diff = { building: { floors: 5 }, name: 'Test' }

// Check if a path is affected by a diff
diffContainsPath(diff, 'building.floors') // true
diffContainsPath(diff, 'building.height') // false

// Extract value at a path
extractDiffValue(diff, 'building.floors') // 5

// Create a diff at a specific path
createDiffAtPath('building.config.enabled', true)
// { building: { config: { enabled: true } } }

General Utilities

import { isDeepEqual, deepClone, isDiffEmpty, mergeDiffs } from '@hvakr/firestate'

// Deep equality check (handles Timestamps, arrays, nested objects)
isDeepEqual(obj1, obj2)

// Deep clone (safe for Firestore operations, handles Timestamps)
const clone = deepClone(original)

// Check if a diff has no changes
if (isDiffEmpty(diff)) return

// Merge two diffs (second takes precedence)
const combined = mergeDiffs(diff1, diff2)

Notes

  • enabled flag — pass enabled: false to useDocument/useCollection to skip the subscription when params aren't ready (e.g., during a route transition).
  • Navigation flicker — changing params rebuilds the listener and briefly shows isLoading: true. To keep the previous data visible across the transition, wrap your param in useDeferredValue.
  • No cross-doc transactions — writes are atomic per document and per collection (via writeBatch), but not across them. For now, use Firestore's runTransaction directly via handle.ref.
  • Per-client undouseUndoManager is local; one user's undo doesn't propagate to others.
  • Multi-tab sync — handled automatically by Firestore's listeners; no extra setup.

Advanced Usage

Creating a Store Manually

For advanced use cases, you can create and manage the store yourself:

import { createStore, createDocumentSubscription } from '@hvakr/firestate'

const store = createStore({
    firestore: db,
    autosave: 1000,
    maxUndoLength: 50,
    onError: (error, context) => {
        // Send to error tracking service
        Sentry.captureException(error, { extra: context })
    }
})

const subscription = createDocumentSubscription({
    store,
    definition: projectDoc,
    docId: '123',
})

subscription.subscribe((state) => {
    console.log('State changed:', state)
})

subscription.load()

// Later: cleanup
subscription.stop()

Custom Undo Manager

Create a standalone undo manager with navigation support:

import { createUndoManager } from '@hvakr/firestate'

const undoManager = createUndoManager({
    maxLength: 50,
    onNavigate: (path) => router.push(path),
})

undoManager.push({
    undo: () => restoreOldValue(),
    redo: () => applyNewValue(),
    groupId: 'myGroup',
    path: '/projects/123',  // Navigate here on undo/redo
    description: 'Update project name',
})

// Subscribe to state changes
const unsubscribe = undoManager.subscribe((state) => {
    console.log('Can undo:', state.canUndo)
    console.log('Can redo:', state.canRedo)
})

Query Constraints

Add Firestore query constraints to collections:

import { where, orderBy, limit } from 'firebase/firestore'

const recentSpaces = useCollection({
    definition: spacesCollection,
    params: { projectId: '123' },
    queryConstraints: [
        where('floor', '>=', 1),
        orderBy('createdAt', 'desc'),
        limit(10),
    ],
})

Handling Errors

const project = useDocument({
    definition: projectDoc,
    params: { projectId: '123' },
})

// Missing documents are not errors — `data` is undefined and `isLoading`
// is false. Render a create/empty state for that case.
if (!project.isLoading && !project.data) {
    return <CreateProject />
}

if (project.error) {
    return <ErrorDisplay error={project.error} />
}

Disabling Autosave

For cases where you want manual control:

const projectDoc = defineDocument({
    schema: ProjectSchema,
    collection: 'projects',
    id: (params) => params.id,
    autosave: 0, // Disable autosave
})

// In component
const project = useDocument({ definition: projectDoc, params })

// Changes won't auto-save
project.update({ name: 'New Name' })

// Manually sync when ready
await project.sync()

Testing

Run tests:

pnpm test

Run tests in watch mode:

pnpm test:watch

Run tests with coverage:

pnpm test:coverage

Mocking in Tests

When testing components that use Firestate, you can mock the hooks:

import { vi } from 'vitest'
import * as firestate from '@hvakr/firestate'

vi.mock('@hvakr/firestate', () => ({
    useDocument: vi.fn(() => ({
        data: { id: '123', name: 'Test Project' },
        update: vi.fn(),
        set: vi.fn(),
        delete: vi.fn(),
        isLoading: false,
        isSynced: true,
        sync: vi.fn(),
        error: undefined,
        ref: {},
    })),
    useUndoManager: vi.fn(() => ({
        canUndo: false,
        canRedo: false,
        undo: vi.fn(),
        redo: vi.fn(),
    })),
}))

Migration from useFirestoreDocument/Collection

If you're currently using custom hooks like useFirestoreDocument and useFirestoreCollection, here's how to migrate:

Before (500+ lines of provider code)

// ProjectProvider.tsx
export const ProjectProvider = ({ children }) => {
    const undoManager = useUndoManager()

    const project = useFirestoreDocument({
        firestore: db,
        collectionPath: 'projects',
        documentId: projectId,
        autosave: 1000,
        onPushUndoAction: undoManager.push,
    })

    const spaces = useFirestoreCollection({
        firestore: db,
        collectionPath: `projects/${projectId}/spaces`,
        autosave: 1000,
        lazy: true,
        onPushUndoAction: undoManager.push,
    })

    // ... 20 more collections ...

    const allSynced = project.isSynced && spaces.isSynced && /* ... */

    // ... lots of memoization and context setup ...
}

After (declarative and minimal)

// schemas.ts
export const projectDoc = defineDocument<Project>({
    collection: 'projects',
    id: (params) => params.projectId,
})

export const spacesCollection = defineCollection<Space>({
    path: (params) => `projects/${params.projectId}/spaces`,
    lazy: true,
})

// Component.tsx
function ProjectEditor({ projectId }) {
    const project = useDocument({ definition: projectDoc, params: { projectId } })
    const spaces = useCollection({ definition: spacesCollection, params: { projectId } })
    const isSynced = useIsSynced() // Automatic!

    // That's it. Undo/redo is automatic.
}

Design Philosophy

  1. Type-first, validator-optional: Plain TypeScript interfaces drive the API; validation is an opt-in escape hatch via Standard Schema
  2. Declarative over imperative: Define what you want, not how to get it
  3. Batteries included: Undo/redo, sync tracking, and conflict resolution work out of the box
  4. Escape hatches: Low-level APIs available when you need them
  5. Framework agnostic core: The subscription system works without React

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Development

# Install dependencies
pnpm install

# Build
pnpm build

# Run tests
pnpm test

# Type check
pnpm typecheck

License

MIT © HVAKR

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors