This document outlines the state management strategy for the Comprehendo application.
We use Zustand as our primary state management library. Zustand is a small, fast, and scalable bearbones state-management solution using simplified flux principles. It makes state management straightforward and integrates well with React.
To handle immutable updates more easily and write more readable mutation logic, we use the Immer middleware for Zustand. This allows us to write "mutating" logic within our state updaters, which Immer then translates into safe, immutable updates.
We employ a single global Zustand store that is composed of multiple slices. Each slice is responsible for a specific domain of the application's state and logic.
Decision Rationale:
- Simplicity of Cross-Slice Interactions: A single store makes it easy for different parts of the state to interact with each other. Actions in one slice can easily
get()state from or call actions in other slices. - Centralized State: Having one source of truth simplifies debugging and understanding the overall application state.
- Middleware Application: Middleware (like
immerandpersistfor localStorage) is applied once to the entire store. - Type Safety: A combined state type (e.g.,
TextGeneratorState) provides a clear, typed overview of the entire application's state.
While separate stores could offer more decoupling, the current level of interaction between our state domains (e.g., quiz logic needing language settings and UI state) makes the single-store-with-slices approach more pragmatic and manageable.
The main store is defined in app/store/textGeneratorStore.ts. It combines various slices using their respective creator functions:
// Example from app/store/textGeneratorStore.ts
export const useTextGeneratorStore = create<TextGeneratorState>()(
persist(
immer((...args) => ({
...createUISlice(...args),
...createSettingsSlice(...args),
...createQuizSlice(...args),
// ... and so on for other slices
})),
{
name: 'text-generator-store',
// ... persistence configuration ...
}
)
);Each create[SliceName]Slice function is a Zustand StateCreator that defines its piece of the state and associated actions.
baseSlice.ts:- Responsibility: Provides common state properties and setters for loading status (
loading), error messages (error), and error visibility (showError). - It's not used directly but composed into other slices that require these base functionalities.
- Responsibility: Provides common state properties and setters for loading status (
uiSlice.ts:- Responsibility: Manages general UI state, such as visibility of login prompts, main content display (
showContent), question sections (showQuestionSection), and explanations (showExplanation). - Composes
baseSlicefor its own loading/error states if needed for UI-specific async operations (though typically loading/error for data fetching resides in the data-specific slice).
- Responsibility: Manages general UI state, such as visibility of login prompts, main content display (
settingsSlice.ts:- Responsibility: Manages user-configurable settings like passage language, target CEFR level (
cefrLevel), and generation languages. - Handles actions that might impact multiple parts of the application when a setting changes (e.g., resetting quiz state when language changes).
- Responsibility: Manages user-configurable settings like passage language, target CEFR level (
quizSlice.ts:- Responsibility: Manages all state related to the current quiz, including the quiz data itself, user's selected answer, feedback, fetching new quizzes, and interactions during a quiz session.
- Relies on
uiSlicefor controlling visibility of quiz-related UI elements. - Relies on
settingsSlicefor CEFR level and language settings. - Relies on
audioSlicefor speech-related actions.
audioSlice.ts:- Responsibility: Manages all aspects of text-to-speech functionality, including playback state (speaking, paused), volume, available voices, selected voice, and speech synthesis utterance management. Also handles word translation caching.
languageSlice.ts:- Responsibility: Manages the application's UI display language (i18n) and related settings.
progressSlice.ts:- Responsibility: Manages user progress tracking, such as user streak and fetching overall progress data. Updates the user's
cefrLevel(insettingsSlice) based on progress.
- Responsibility: Manages user progress tracking, such as user streak and fetching overall progress data. Updates the user's
-
Identify the Correct Slice:
- Before adding new state or actions, determine which existing slice is most relevant to the domain of that state.
- For example, state related to quiz interaction goes into
quizSlice; state for general UI visibility goes intouiSlice.
-
Modifying Slice State:
- Within a slice's action, use the
setfunction provided by Zustand. Thanks to Immer, you can write "mutative" logic:// Inside a slice creator someAction: (newValue) => { set((state) => { state.someProperty = newValue; // Immer handles immutable update }); },
- Within a slice's action, use the
-
Accessing Other Slices' State or Actions:
-
Use the
get()function (the second argument to the slice creator(set, get) => ({...})) to access the entire store's state or other actions:// Inside a slice creator anotherAction: () => { const currentPassageLanguage = get().passageLanguage; // Reading from settingsSlice const isLoading = get().loading; // Reading from baseSlice (composed) if (!isLoading) { get().someActionInAnotherSlice(currentPassageLanguage); // Calling another slice's action } };
-
-
Setting Other Slices' State:
- Avoid direct mutation: Do NOT directly set state for another slice like
set(state => { state.otherSliceProperty = value; }). This breaks encapsulation. - Call setters/actions: Instead, call an appropriate action/setter from the target slice using
get().targetSetter(value). This was a key part of our recent refactor.
- Avoid direct mutation: Do NOT directly set state for another slice like
-
Async Actions:
- Perform asynchronous operations (e.g., API calls) within your action, then use
setto update the state based on the result. Manage loading and error states (typically frombaseSlice) accordingly.fetchData: async () => { set(state => { state.loading = true; state.error = null; }); try { const data = await apiCall(); set(state => { state.data = data; state.loading = false; }); get().setShowContent(true); // Example: show content after data is loaded } catch (e) { const error = e instanceof Error ? e.message : 'Unknown error'; set(state => { state.error = error; state.loading = false; }); get().setShowContent(false); // Example: hide content on error } },
- Perform asynchronous operations (e.g., API calls) within your action, then use
-
Keep Slices Focused: Try to keep each slice focused on its specific domain to maintain clarity and separation of concerns.
-
Type Everything: Ensure all state properties and action payloads/return types are clearly typed. The combined
TextGeneratorStatehelps ensure overall type safety.