Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions invokeai/frontend/web/src/app/store/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# State Management Guide

This frontend uses two complementary state layers:

- **Redux Toolkit store** for durable, persisted, or undoable application state that participates in middleware, RTK
Query caching, or needs to be visible in devtools.
- **Nanostores atoms** for lightweight, imperative values that behave like configuration flags or transient UI helpers.

Keeping the contract for each layer explicit makes it easier for both humans and AI agents to add or refactor state
without guessing.

## When to reach for Redux

Use a slice when any of the following are true:

- The value must survive reloads via `redux-remember`, feed undo/redo history, or plug into listener middleware.
- Multiple features consume the value through selectors/memoization and we want time-travel/debug tooling support.
- The state is derived from API responses or dispatches actions that other middleware reacts to (e.g. queue, gallery,
canvas, workflow, parameters, system).

### Adding a slice

Redux slices live in `features/<feature>/store`. When adding one:

1. Create the slice with `createSlice`, a Zod schema, and an exported `SliceConfig<T>`.
2. Call `registerSlice(yourSliceConfig);` inside the registration block in `app/store/store.ts`. The reducer map is
built automatically (undoable slices remain wrapped for you).
3. Decide which keys should persist and update the slice’s `persistConfig` denylist/migration accordingly.
4. Expose selectors in the slice file so callers avoid reaching into `RootState` manually.

> Tip: Because registration is manual, double-check both `SLICE_CONFIGS` and `ALL_REDUCERS` before opening a PR—missing
> one of them will compile but crash on rehydration.

## When to prefer nanostores

Nanostores excel for simple, ephemeral state that is driven by host configuration or narrow feature surface areas (e.g.
modal toggles, auth tokens, injected UI overrides).

Choose a nanostore when:

- The value does **not** need to persist, participate in undo, or trigger Redux middleware.
- Only a handful of components or hooks care about it, often within a single feature module.
- Updating it synchronously from `useEffect` or external callbacks is simpler than dispatching actions.

### Pattern

We generally pair an atom with hooks:

```ts
const initialState = { isOpen: false };
const $example = atom(initialState);

export const useExampleState = () => useStore($example);
export const useExampleApi = () =>
useMemo(
() => ({
open: () => $example.set({ isOpen: true }),
close: () => $example.set(initialState),
}),
[]
);
```

Keep nano state colocated with the feature (`features/<feature>/store/state.ts`) so it is easy to discover. When the
state graduates to something that needs persistence or devtools visibility, migrate it into a Redux slice and remove the
atom.

## Decision checklist

| Question | Use Redux | Use nanostore |
| -------------------------------------------------------------------- | --------- | ------------- |
| Needs undo/history or persistence? | ✅ | ❌ |
| Needs to trigger listener middleware / RTK Query matchers? | ✅ | ❌ |
| Only toggles a modal or stores host-provided config? | ❌ | ✅ |
| Debuggable via Redux devtools or inspectable across features needed? | ✅ | ❌ |

If the answers are mixed, favour Redux—the manual boilerplate is worth the consistency.

## Additional notes

- Keep prop-to-atom syncing (`InvokeAIUI`, host integration) inside `useEffect` hooks so atoms behave like runtime
configuration switches.
- Avoid mixing direct Redux access and nanostore state for the same concern; pick one abstraction per capability.
- When deprecating a slice, clean up its registrations in `app/store/store.ts` and update `RootState` consumers before
deleting the file. The `changeBoardModal` modal is a good reference for migrating a simple slice to nanostores.

This guide should help both humans and AI collaborators make consistent choices and reduce churn when the application’s
state model evolves.
107 changes: 51 additions & 56 deletions invokeai/frontend/web/src/app/store/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ThunkDispatch, TypedStartListening, UnknownAction } from '@reduxjs/toolkit';
import type { ReducersMapObject, Slice, ThunkDispatch, TypedStartListening, UnknownAction } from '@reduxjs/toolkit';
import { addListener, combineReducers, configureStore, createAction, createListenerMiddleware } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { errorHandler } from 'app/store/enhancers/reduxRemember/errors';
Expand All @@ -20,7 +20,6 @@ import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMi
import { deepClone } from 'common/util/deepClone';
import { merge } from 'es-toolkit';
import { omit, pick } from 'es-toolkit/compat';
import { changeBoardModalSliceConfig } from 'features/changeBoardModal/store/slice';
import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice';
import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice';
import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice';
Expand Down Expand Up @@ -50,6 +49,7 @@ import { api } from 'services/api';
import { authToastMiddleware } from 'services/api/authToastMiddleware';
import type { JsonObject } from 'type-fest';

import type { SliceConfig } from './types';
import { reduxRememberDriver } from './enhancers/reduxRemember/driver';
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
Expand All @@ -61,64 +61,59 @@ export const listenerMiddleware = createListenerMiddleware();

const log = logger('system');

// When adding a slice, add the config to the SLICE_CONFIGS object below, then add the reducer to ALL_REDUCERS.
const SLICE_CONFIGS = {
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig,
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig,
[canvasSliceConfig.slice.reducerPath]: canvasSliceConfig,
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
[configSliceConfig.slice.reducerPath]: configSliceConfig,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig,
[lorasSliceConfig.slice.reducerPath]: lorasSliceConfig,
[modelManagerSliceConfig.slice.reducerPath]: modelManagerSliceConfig,
[nodesSliceConfig.slice.reducerPath]: nodesSliceConfig,
[paramsSliceConfig.slice.reducerPath]: paramsSliceConfig,
[queueSliceConfig.slice.reducerPath]: queueSliceConfig,
[refImagesSliceConfig.slice.reducerPath]: refImagesSliceConfig,
[stylePresetSliceConfig.slice.reducerPath]: stylePresetSliceConfig,
[systemSliceConfig.slice.reducerPath]: systemSliceConfig,
[uiSliceConfig.slice.reducerPath]: uiSliceConfig,
[upscaleSliceConfig.slice.reducerPath]: upscaleSliceConfig,
[videoSliceConfig.slice.reducerPath]: videoSliceConfig,
[workflowLibrarySliceConfig.slice.reducerPath]: workflowLibrarySliceConfig,
[workflowSettingsSliceConfig.slice.reducerPath]: workflowSettingsSliceConfig,
type GenericSlice = Slice<any, any, string>;
type RegisteredSliceConfig = SliceConfig<GenericSlice>;

const registeredSliceConfigs: RegisteredSliceConfig[] = [];

// Register each slice config once—reducers and persistence metadata are derived from this list.
const registerSlice = (config: RegisteredSliceConfig) => {
registeredSliceConfigs.push(config);
};

// TS makes it really hard to dynamically create this object :/ so it's just hardcoded here.
// Remember to wrap undoable reducers in `undoable()`!
const ALL_REDUCERS = {
[api.reducerPath]: api.reducer,
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig.slice.reducer,
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig.slice.reducer,
// Undoable!
[canvasSliceConfig.slice.reducerPath]: undoable(
canvasSliceConfig.slice.reducer,
canvasSliceConfig.undoableConfig?.reduxUndoOptions
),
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig.slice.reducer,
[configSliceConfig.slice.reducerPath]: configSliceConfig.slice.reducer,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig.slice.reducer,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig.slice.reducer,
[lorasSliceConfig.slice.reducerPath]: lorasSliceConfig.slice.reducer,
[modelManagerSliceConfig.slice.reducerPath]: modelManagerSliceConfig.slice.reducer,
// Undoable!
[nodesSliceConfig.slice.reducerPath]: undoable(
nodesSliceConfig.slice.reducer,
nodesSliceConfig.undoableConfig?.reduxUndoOptions
),
[paramsSliceConfig.slice.reducerPath]: paramsSliceConfig.slice.reducer,
[queueSliceConfig.slice.reducerPath]: queueSliceConfig.slice.reducer,
[refImagesSliceConfig.slice.reducerPath]: refImagesSliceConfig.slice.reducer,
[stylePresetSliceConfig.slice.reducerPath]: stylePresetSliceConfig.slice.reducer,
[systemSliceConfig.slice.reducerPath]: systemSliceConfig.slice.reducer,
[uiSliceConfig.slice.reducerPath]: uiSliceConfig.slice.reducer,
[upscaleSliceConfig.slice.reducerPath]: upscaleSliceConfig.slice.reducer,
[videoSliceConfig.slice.reducerPath]: videoSliceConfig.slice.reducer,
[workflowLibrarySliceConfig.slice.reducerPath]: workflowLibrarySliceConfig.slice.reducer,
[workflowSettingsSliceConfig.slice.reducerPath]: workflowSettingsSliceConfig.slice.reducer,
registerSlice(canvasSessionSliceConfig);
registerSlice(canvasSettingsSliceConfig);
registerSlice(canvasSliceConfig);
registerSlice(configSliceConfig);
registerSlice(dynamicPromptsSliceConfig);
registerSlice(gallerySliceConfig);
registerSlice(lorasSliceConfig);
registerSlice(modelManagerSliceConfig);
registerSlice(nodesSliceConfig);
registerSlice(paramsSliceConfig);
registerSlice(queueSliceConfig);
registerSlice(refImagesSliceConfig);
registerSlice(stylePresetSliceConfig);
registerSlice(systemSliceConfig);
registerSlice(uiSliceConfig);
registerSlice(upscaleSliceConfig);
registerSlice(videoSliceConfig);
registerSlice(workflowLibrarySliceConfig);
registerSlice(workflowSettingsSliceConfig);

const sliceConfigList = registeredSliceConfigs as readonly RegisteredSliceConfig[];

const SLICE_CONFIGS = sliceConfigList.reduce<Record<string, RegisteredSliceConfig>>((acc, config) => {
acc[config.slice.reducerPath] = config;
return acc;
}, {});

const buildReducers = (): ReducersMapObject => {
const reducers: ReducersMapObject = {
[api.reducerPath]: api.reducer,
};

sliceConfigList.forEach((config) => {
reducers[config.slice.reducerPath] = config.undoableConfig
? undoable(config.slice.reducer, config.undoableConfig.reduxUndoOptions)
: config.slice.reducer;
});

return reducers;
};

const ALL_REDUCERS = buildReducers();

const rootReducer = combineReducers(ALL_REDUCERS);

const rememberedRootReducer = rememberReducer(rootReducer);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,25 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, ConfirmationAlertDialog, Flex, FormControl, Text } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import {
changeBoardReset,
isModalOpenChanged,
selectChangeBoardModalSlice,
} from 'features/changeBoardModal/store/slice';
import { useChangeBoardModalApi, useChangeBoardModalState } from 'features/changeBoardModal/store/state';
import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images';
import { useAddVideosToBoardMutation, useRemoveVideosFromBoardMutation } from 'services/api/endpoints/videos';

const selectImagesToChange = createSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.image_names
);

const selectVideosToChange = createSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.video_ids
);

const selectIsModalOpen = createSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.isModalOpen
);

const ChangeBoardModal = () => {
useAssertSingleton('ChangeBoardModal');
const dispatch = useAppDispatch();
const currentBoardId = useAppSelector(selectSelectedBoardId);
const [selectedBoardId, setSelectedBoardId] = useState<string | null>();
const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true });
const isModalOpen = useAppSelector(selectIsModalOpen);
const imagesToChange = useAppSelector(selectImagesToChange);
const videosToChange = useAppSelector(selectVideosToChange);
const changeBoardModalState = useChangeBoardModalState();
const changeBoardModal = useChangeBoardModalApi();
const imagesToChange = changeBoardModalState.imageNames;
const videosToChange = changeBoardModalState.videoIds;
const isModalOpen = changeBoardModalState.isOpen;
const [addImagesToBoard] = useAddImagesToBoardMutation();
const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation();
const [addVideosToBoard] = useAddVideosToBoardMutation();
Expand All @@ -61,9 +42,8 @@ const ChangeBoardModal = () => {
const value = useMemo(() => options.find((o) => o.value === selectedBoardId), [options, selectedBoardId]);

const handleClose = useCallback(() => {
dispatch(changeBoardReset());
dispatch(isModalOpenChanged(false));
}, [dispatch]);
changeBoardModal.close();
}, [changeBoardModal]);

const handleChangeBoard = useCallback(() => {
if (!selectedBoardId || (imagesToChange.length === 0 && videosToChange.length === 0)) {
Expand All @@ -90,10 +70,10 @@ const ChangeBoardModal = () => {
});
}
}
dispatch(changeBoardReset());
changeBoardModal.close();
}, [
addImagesToBoard,
dispatch,
changeBoardModal,
imagesToChange,
videosToChange,
removeImagesFromBoard,
Expand Down

This file was deleted.

Loading
Loading