ADR-004: Frontend State Management with Zustand
Statusโ
Accepted - Adopted across all frontend state management
Contextโ
The frontend needed a state management solution that would:
- Provide type safety for all state operations
- Enable modular, composable store architecture
- Support selective persistence of user preferences
- Integrate seamlessly with React components
- Avoid the complexity of Redux while maintaining predictable state updates
Decisionโ
We will use Zustand as our primary state management library with specific architectural patterns:
1. Modular Store Compositionโ
Large stores are composed of smaller, focused modules to improve maintainability:
// Main store composes multiple modules
export const useSitesStore = create<SitesStore>()((set, get) => {
const stateActions = createSitesStateActions(set, get);
const syncActions = createSiteSyncActions({
getSites: () => get().sites,
setSites: stateActions.setSites,
});
const operationsActions = createSiteOperationsActions({
addSite: stateActions.addSite,
getSites: () => get().sites,
syncSitesFromBackend: syncActions.syncSitesFromBackend,
});
return {
...initialSitesState,
...stateActions,
...operationsActions,
...syncActions,
};
});
2. Typed Store Interfacesโ
All stores use comprehensive TypeScript interfaces:
export interface SitesStore extends SitesState {
// State actions
addSite: (site: Site) => void;
setSites: (sites: Site[]) => void;
removeSite: (identifier: string) => void;
// Operations actions
createSite: (siteData: CreateSiteData) => Promise<Site>;
deleteSite: (identifier: string) => Promise<void>;
// Sync actions
syncSitesFromBackend: () => Promise<void>;
}
3. Consistent Action Loggingโ
All store actions include consistent logging for debugging:
setSelectedSite: (site: Site | undefined) => {
logStoreAction("UIStore", "setSelectedSite", { site });
set({ selectedSiteId: site ? site.identifier : undefined });
};
4. Selective Persistenceโ
UI preferences are persisted while transient state remains in memory:
export const useUIStore = create<UIStore>()(
persist(
(set) => ({
// State and actions...
}),
{
name: "uptime-watcher-ui",
partialize: (state) => ({
// Only persist user preferences
showAdvancedMetrics: state.showAdvancedMetrics,
siteDetailsChartTimeRange: state.siteDetailsChartTimeRange,
// Exclude transient state
// showSettings: false,
// showSiteDetails: false,
}),
}
)
);
5. Error Integrationโ
Stores integrate with the error handling system:
const performAction = async () => {
await withErrorHandling(async () => {
const result = await window.electronAPI.sites.addSite(data);
addSite(result); // Update store state
return result;
}, errorStore);
};
Store Categoriesโ
1. Domain Storesโ
Handle specific business domain state:
useSitesStore
- Site and monitor managementuseMonitorTypesStore
- Monitor type configurations
2. UI Storesโ
Manage user interface state:
useUIStore
- Modal visibility, selected items, user preferencesuseErrorStore
- Global error and loading states
3. System Storesโ
Handle application-level state:
useUpdatesStore
- Update management and notificationsuseSettingsStore
- Application settings and configuration
State Update Patternsโ
1. Immutable Updatesโ
All state updates use immutable patterns:
addSite: (site: Site) => {
set((state) => ({
sites: [...state.sites, site],
}));
};
2. Async Operationsโ
Async operations are handled in store actions:
createSite: async (siteData: CreateSiteData) => {
const result = await window.electronAPI.sites.addSite(siteData);
get().addSite(result);
return result;
};
3. Derived Stateโ
Complex derived state uses selectors:
const sitesWithMonitorCount = useSitesStore((state) =>
state.sites.map((site) => ({
...site,
monitorCount: site.monitors.length,
}))
);
Integration with IPCโ
Event-Driven Updatesโ
Stores listen to IPC events for backend state synchronization:
useEffect(() => {
const cleanup = window.electronAPI.events.onStateSyncEvent((data) => {
if (data.type === "sites-updated") {
syncSitesFromBackend();
}
});
return cleanup;
}, [syncSitesFromBackend]);
Optimistic Updatesโ
UI updates optimistically, with rollback on failure:
deleteSite: async (identifier: string) => {
// Optimistic update
const originalSites = get().sites;
removeSite(identifier);
try {
await window.electronAPI.sites.removeSite(identifier);
} catch (error) {
// Rollback on failure
setSites(originalSites);
throw error;
}
};
Testing Patternsโ
Store Testingโ
Stores are tested with renderHook from @testing-library:
describe("useSitesStore", () => {
beforeEach(() => {
const store = useSitesStore.getState();
act(() => {
store.setSites([]);
});
});
it("should add site", () => {
const { result } = renderHook(() => useSitesStore());
act(() => {
result.current.addSite(mockSite);
});
expect(result.current.sites).toContain(mockSite);
});
});
Consequencesโ
Positiveโ
- Type safety - Compile-time checking prevents runtime errors
- Modularity - Large stores broken into focused, testable modules
- Performance - Efficient updates and selective subscriptions
- Persistence - User preferences maintained across sessions
- Integration - Seamless integration with error handling and IPC
- Developer experience - Simple API with powerful capabilities
Negativeโ
- Learning curve - Developers need to understand Zustand patterns
- Boilerplate - Module composition requires more initial setup
- Complexity - Async operations and error handling add complexity
Implementation Guidelinesโ
1. Store Structureโ
// 1. Define interfaces
interface StoreState {
/* state shape */
}
interface StoreActions {
/* action signatures */
}
interface Store extends StoreState, StoreActions {}
// 2. Create store with composition
export const useStore = create<Store>()((set, get) => ({
// Initial state
...initialState,
// Actions
action: (params) => {
logStoreAction("StoreName", "action", { params });
set((state) => ({
/* immutable update */
}));
},
}));
2. Persistence Configurationโ
persist(storeImplementation, {
name: "unique-storage-key",
partialize: (state) => ({
// Only include persistent fields
}),
});
3. Error Integrationโ
Always wrap async operations with error handling utilities.
Complianceโ
All frontend state follows these patterns:
- Type-safe store interfaces
- Consistent action logging
- Modular composition for complex stores
- Selective persistence for user preferences
- Integration with error handling system