State management
We use Zustand to manage complex state in SlideSpeak. Zustand is a minimal state management library that provides a simple API without the boilerplate of libraries like Redux.
Why Zustand?
Section titled “Why Zustand?”For complex features like the presentation editor, React’s useState becomes unwieldy when you need to share state across multiple components or coordinate complex state updates. Zustand provides a centralized store that components can access directly.
When to Use Zustand
Section titled “When to Use Zustand”Use Zustand when you need to:
- Share state across multiple components that aren’t closely related
- Manage complex state with many interdependent properties
- Coordinate state updates that affect multiple parts of the UI
- Track application state that persists across navigation
Don’t use Zustand for:
- Simple component-local state (use
useState) - Server data fetching (use React Query)
Folder Structure
Section titled “Folder Structure”Organize your Zustand store files within your feature directory:
src/features/[feature-name]/ state/ [feature]-store.ts # Store definition and hooks actions/ actions.ts # Aggregated actions export common.ts # Common actions [specific].ts # Specific action groupsExample: Editor Store Structure
Section titled “Example: Editor Store Structure”src/features/presentation/edit/state/ editor-store.ts # Store definition actions/ actions.ts # All actions exported together common.ts # Common actions (init, reset) text.ts # Text-related actions slide.ts # Slide-related actions shape.ts # Shape-related actions # ... more action filesStore Setup Pattern
Section titled “Store Setup Pattern”We follow a consistent pattern for all Zustand stores. The key philosophy is separating state from actions to keep stores simple and maintainable.
Separation of Concerns
Section titled “Separation of Concerns”Store files ([feature]-store.ts) contain only:
- State type definitions
- Default state
- Store creation
- Custom hooks for reading state
Action files (actions/) contain only:
- Functions that update state
- Business logic for state changes
- Coordinated state updates
This separation provides several benefits:
- Simpler stores: Store files focus solely on state shape and access patterns
- Better organization: Actions can be split into logical groups (common, text, slide, etc.)
- Easier testing: Actions can be tested independently without React components
- Clearer intent: It’s immediately clear what modifies state vs. what reads it
Here’s how to set up a new store:
1. Define State Type and Default State
Section titled “1. Define State Type and Default State”File: src/features/[feature]/state/[feature]-store.ts
type FeatureState = { data: DataType | undefined; selectedId: string | undefined; isLoading: boolean;};
export const DEFAULT_FEATURE_STATE: FeatureState = { data: undefined, selectedId: undefined, isLoading: false,};2. Create the Store
Section titled “2. Create the Store”File: src/features/[feature]/state/[feature]-store.ts
import { create } from 'zustand/index';
export const useFeatureStore = create<FeatureState>(() => DEFAULT_FEATURE_STATE);3. Export Helper Functions
Section titled “3. Export Helper Functions”File: src/features/[feature]/state/[feature]-store.ts
export const getFeatureState = useFeatureStore.getState;export const setFeatureState = useFeatureStore.setState;4. Create Custom Hooks for State Access
Section titled “4. Create Custom Hooks for State Access”File: src/features/[feature]/state/[feature]-store.ts
export const useFeatureStoreValue = <K extends keyof FeatureState>(key: K): FeatureState[K] => useFeatureStore(it => it[key]);
export const useFeatureData = () => useFeatureStore(it => it.data);
export const useFeatureSelection = () => { const data = useFeatureStore(it => it.data); const selectedId = useFeatureStoreValue('selectedId'); if (!data || !selectedId) return undefined; return data.items.find(item => item.id === selectedId);};For object selections, use useShallow to prevent unnecessary re-renders:
import { useShallow } from 'zustand/shallow';
export const useFeatureSize = () => useFeatureStore( useShallow(({ data }) => { if (!data) return undefined; return { width: data.width, height: data.height }; }), );5. Create Actions File
Section titled “5. Create Actions File”File: src/features/[feature]/state/actions/[feature]-actions.ts
Separate actions from state in a dedicated file:
import { getFeatureState, setFeatureState, DEFAULT_FEATURE_STATE } from '../[feature]-store';
export const FeatureActions = { updateData: (data: DataType) => { setFeatureState({ data }); },
selectItem: (id: string) => { const { data } = getFeatureState(); if (!data?.items.find(item => item.id === id)) return; setFeatureState({ selectedId: id }); },
reset: () => { setFeatureState(DEFAULT_FEATURE_STATE); },};For larger features, split actions into multiple files and aggregate them:
File: src/features/[feature]/state/actions/common.ts
import { setFeatureState, DEFAULT_FEATURE_STATE } from '../[feature]-store';
export const _FeatureCommonActions = { reset: () => { setFeatureState(DEFAULT_FEATURE_STATE); },};File: src/features/[feature]/state/actions/actions.ts
import { _FeatureCommonActions } from './common';import { _FeatureDataActions } from './data';
export const FeatureActions = { ..._FeatureCommonActions, ..._FeatureDataActions,};6. Use in Components
Section titled “6. Use in Components”File: src/features/[feature]/components/[component].tsx
import { useFeatureData, useFeatureSelection } from '@/features/[feature]/state/[feature]-store';import { FeatureActions } from '@/features/[feature]/state/actions/actions';
const FeatureComponent = () => { const data = useFeatureData(); const selection = useFeatureSelection();
const handleSelect = (id: string) => { FeatureActions.selectItem(id); };
return ( <div> {data && ( <ul> {data.items.map(item => ( <li key={item.id} onClick={() => handleSelect(item.id)}> {item.name} </li> ))} </ul> )} </div> );};Real Example: Editor Store
Section titled “Real Example: Editor Store”The presentation editor uses this pattern extensively. Here’s how it’s structured:
export const DEFAULT_EDITOR_STATE: EditorState = { dbPresentationId: undefined, documentId: undefined, presentation: undefined, template: undefined, selectedSlideId: undefined, selectedShapeIds: [], selectedTextId: undefined, // ... more state};
export const useEditorStore = create<EditorState>(() => DEFAULT_EDITOR_STATE);
export const getEditorState = useEditorStore.getState;export const setEditorState = useEditorStore.setState;
export const useEditorStoreValue = <K extends keyof EditorState>(key: K): EditorState[K] => useEditorStore(it => it[key]);
export const useEditorSelectedSlide = () => { const presentation = useEditorStore(it => it.presentation); const selectedSlideId = useEditorStoreValue('selectedSlideId'); return presentation?.slides.find(slide => slide.id === selectedSlideId);};Actions are organized in separate files and exported as a single object:
export const EditorActions = { ..._EditorCommonActions, ..._EditorTextActions, ..._EditorSlideActions, // ... more action groups};Best Practices
Section titled “Best Practices”- Use
useShallowwhen selecting multiple properties from objects to prevent unnecessary re-renders - Separate actions from state - Keep actions in dedicated files
- Create custom hooks for computed values and frequently accessed state
- Export query keys - Export state keys as constants if they need to be referenced elsewhere
- Use
getStatefor actions that need to read current state without subscribing - Use
setStatefor actions that update state