Skip to content

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.

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.

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)

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 groups
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 files

We follow a consistent pattern for all Zustand stores. The key philosophy is separating state from actions to keep stores simple and maintainable.

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:

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,
};

File: src/features/[feature]/state/[feature]-store.ts

import { create } from 'zustand/index';
export const useFeatureStore = create<FeatureState>(() => DEFAULT_FEATURE_STATE);

File: src/features/[feature]/state/[feature]-store.ts

export const getFeatureState = useFeatureStore.getState;
export const setFeatureState = useFeatureStore.setState;

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 };
}),
);

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,
};

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>
);
};

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
};
  • Use useShallow when 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 getState for actions that need to read current state without subscribing
  • Use setState for actions that update state