Skip to main content

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 management
  • useMonitorTypesStore - Monitor type configurations

2. UI Storesโ€‹

Manage user interface state:

  • useUIStore - Modal visibility, selected items, user preferences
  • useErrorStore - Global error and loading states

3. System Storesโ€‹

Handle application-level state:

  • useUpdatesStore - Update management and notifications
  • useSettingsStore - 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