State
This cookbook demonstrates @equinor/fusion-framework-module-state and the useAppState hook, from
a zero-config useState-like default to real-time replication with a remote CouchDB.
Fusion Framework State Cookbook
A cookbook demonstrating @equinor/fusion-framework-module-state and the useAppState hook -
from a zero-config useState-like default, to real-time replication with a remote CouchDB.
π― Learning Objectives
After working through this cookbook, you will understand:
- How to read and write persistent state with
useAppState, and when to give it adefaultValue - That the state module works with no configuration at all -
enableAppState(appConfigurator)
is enough to get local, persistent storage - How to opt in to CouchDB replication via
PouchDbSyncStorage, for offline-first, multi-tab, and
multi-device sync - How to observe the framework's
onStateSync.*events to build sync-status UI - Best practices for naming state keys, typing state, and validating complex state shapes
ποΈ Setup
Prerequisites
- Node.js 18+
- pnpm package manager
- Docker (only if you want to try the CouchDB replication demo)
Quick Start
pnpm install
pnpm devThat's it - no database or Docker setup required. Every page uses useAppState with the state
module's own default storage (a local, per-app PouchDB database with no remote sync), the same
zero-config behavior any consuming app gets.
Optional: Local CouchDB Replication Demo
The Profile and Todos pages, and the sync-event indicators shown across the app, are far more
interesting with real replication - open the app in two browser tabs and watch changes in one
appear in the other. To try that:
pnpm couchdb:start # starts a local CouchDB in Docker
cp .env.example .env # sets FUSION_SPA_COUCHDB_URL
pnpm dev # restart so Vite picks up the new .env- CouchDB admin UI: http://localhost:5984/_utils (
admin/admin) - Stop it with
pnpm couchdb:stop, or remove the container and volume withpnpm couchdb:clean
Unset FUSION_SPA_COUCHDB_URL (or delete .env) to go back to the zero-config default storage.
π Key Concepts
Default Storage vs. CouchDB Replication
enableAppState (from @equinor/fusion-framework-react-app/state) registers the state module and
scopes it to the app's own key. Unless you call config.setStorage(...), the module resolves its
own default storage: a local PouchDB database, optionally upgraded to sync with the Fusion App
State backend when the app also has serviceDiscovery and http configured. In a standalone
cookbook like this one, that means local-only, persistent, per-browser storage out of the box.
PouchDbSyncStorage (from @equinor/fusion-framework-module-state/storage) is what you reach for
when you do want replication - it takes a local database, a remote database, andPouchDB.Replication.SyncOptions, and keeps the two in continuous, bidirectional sync:
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β React App β β PouchDB β β CouchDB β
β β β (Local Store) β β (Remote DB) β
β βββββββββββββββ β β β β β
β β useAppState βββΌβββββΌβΊ Local Storage ββββββΌβΊ Remote Storage β
β β Hooks β β β β β β
β βββββββββββββββ β β β’ Offline-first β β β’ Persistence β
β β β β’ Instant UI β β β’ Multi-user β
β β’ UI Updates β β β’ Auto-sync β β β’ Backup β
β β’ User Actions β β β’ Conflict res. β β β’ HTTP API β
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββSee src/config.ts: it enables PouchDbSyncStorage only whenFUSION_SPA_COUCHDB_URL is set, otherwise it calls enableAppState(appConfigurator) with noconfigure callback at all.
State Management with useAppState
Use useAppState just like React's useState, but with automatic persistence (and replication,
if configured):
import { useAppState } from '@equinor/fusion-framework-react-app/state';
const [state, setState] = useAppState<MyState>('my.state.key', {
defaultValue: {
value: '',
updatedAt: new Date().toISOString(),
},
});
// Updates are persisted (and replicated, when CouchDB sync is configured) automatically
setState((prev) => ({
...prev,
value: 'New Value',
updatedAt: new Date().toISOString(),
}));Omit defaultValue and the state starts as undefined until something sets it - useful for state
that genuinely doesn't exist yet, like user.lastLogin.
Observing Sync Events
When CouchDB replication is enabled, the state module dispatches onStateSync.status,onStateSync.change, onStateSync.complete, and onStateSync.error events through the app'sevent module. @equinor/fusion-framework-react-app's useStateSyncEvents hook subscribes to
them for you and returns a bounded, typed event log -src/components/SyncEvents/SyncStatusMonitor.tsx
shows the pattern:
import { useStateSyncEvents } from '@equinor/fusion-framework-react-app/state';
const events = useStateSyncEvents(20);
const lastEvent = events.at(-1);π Code Structure
src/
βββ App.tsx # Main application entry point
βββ config.ts # State module setup - default storage vs. CouchDB replication
βββ index.ts # App bootstrap
βββ Router.tsx # Route tree (basics, profile, todos)
βββ components/
β βββ ProfileManager/ # Profile management
β βββ SyncEvents/ # Replication status and event log
β βββ Todo/ # Todo list
βββ pages/
βββ Basics.tsx # useAppState fundamentals - boolean, string, optional state
βββ Home.tsx # Overview and navigation
βββ Profile.tsx # Object state with replication
βββ Todo.tsx # List state with replicationπ§ͺ Examples in This Cookbook
1. Basics
The fundamentals of useAppState - a boolean, a string, and an optional value with nodefaultValue - before the replicated pages layer sync on top.
2. Profile Manager
- Object state - a single
useAppStatekey holding a nested profile (name, email,
preferences) - Immutable updates - every field spreads the previous profile and overwrites just its part,
no reducer or action-creator library required - Real-time updates - changes sync across browser tabs and devices when CouchDB replication is
enabled
3. Sync Status Monitor
Real-time monitoring of replication status, built entirely on the framework's onStateSync.*
events (see Observing Sync Events):
- π’ Active - syncing
- π΅ Paused - up to date, waiting for changes
- π΄ Error - connection or replication failure
4. Todo List
- Array state - a list nested inside an object, added to, updated in place, and filtered with
plain array methods (map,filter) - Optimistic updates - instant UI feedback, persisted (and replicated) in the background
π§ Advanced Configuration
Custom Sync Options
Fine-tune replication behavior via PouchDbSyncStorage's syncOptions
(PouchDB.Replication.SyncOptions):
new PouchDbSyncStorage({
localDb: { name_or_instance: 'cookbook_app_state' },
remoteDb: { name_or_instance: couchdbUrl },
syncOptions: {
live: true, // Enable continuous replication
retry: true, // Retry on connection failure
heartbeat: 10000, // Heartbeat interval (ms)
timeout: 30000, // Request timeout (ms)
},
});Troubleshooting
CouchDB connection refused
curl http://localhost:5984/ # check if CouchDB is running pnpm couchdb:stop && pnpm couchdb:startNothing syncs, but no errors either - confirm
.envexists (copied from.env.example)
and thatpnpm devwas restarted after creating it; Vite only reads.envat startup.Authentication errors - verify credentials
admin/adminagainst
http://localhost:5984/_utils.
π‘ Best Practices
1. State Key Organization
Use hierarchical naming for better organization:
// β
Good - hierarchical, descriptive
'user.profile.personal'
'user.preferences.theme'
'app.settings.notifications'
'feature.dashboard.filters'
// β Avoid - flat, unclear
'userdata'
'settings'
'stuff'2. Use Strong Typing
// β
Good - strong typing
interface UserProfile {
id: string;
name: string;
email: string;
}
const [user, setUser] = useAppState<UserProfile>('user.profile');
// β Avoid - weak typing
const [user, setUser] = useAppState('user.profile');3. Validate Complex Schemas
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
email: z.string().email(),
});
type UserProfile = z.infer<typeof userSchema>;
// β
Good - strong typing with validation
const useMyUser = () => {
const [value, setValue] = useAppState<UserProfile>('user.profile');
const setUser = useCallback(
(user: UserProfile) => {
if (userSchema.safeParse(user).success) {
setValue(user);
return true;
}
console.warn('Provided user is invalid');
return false;
},
[setValue],
);
if (value !== undefined && !userSchema.safeParse(value).success) {
console.warn('Current user state is invalid');
return [null, setUser] as const;
}
return [value, setUser] as const;
};