Redux is a common choice for React state management, but most of the guidance available targets single-page applications. Large monolithic apps face a different problem: any given page may need only a handful of reducers out of the full set the application supports, and that subset can vary by page. Satisfying that without shipping the entire reducer set on every page requires an async reducer registration mechanism and a typing strategy that still knows about every reducer that could ever exist.
Architecture goals
The design rests on two requirements:
- The store must be creatable in a way that permits reducers to be registered asynchronously, so the code backing those reducers can be loaded later.
- The store must be typed against the full set of reducers that can ever be registered, preserving static typing for components at runtime.
Registering reducers after store creation
This article assumes working knowledge of Redux, React, React-Redux, TypeScript, and uses a little Lodash for convenience. The final version of all the code is available on GitHub.
// redux-utils/store.ts
import { combineReducers, createStore, Store } from "redux";
import { ReducerMap, StoreShape } from "./types";
let reducerMap: ReducerMap = {};
const store = createStore(combineReducers(reducerMap));
export function getStore() {
return store;
}
export function registerReducer(newReducers: ReducerMap): Store {
reducerMap = { ...reducerMap, ...newReducers };
// We probably want to validate we're not replacing reducers that already
// exist here but that exercise is left up to the reader.
store.replaceReducer(combineReducers(reducerMap));
return store;
}
The module keeps a map of the reducers it currently holds and swaps in new ones as they arrive, then calls replaceReducer on the store with the whole map. replaceReducer preserves the state of reducers that were already present and dispatches an INIT action so newly added reducers populate their default state. That behavior is what makes code splitting possible: registration can happen at any time, after the store exists.
Alongside the store instance itself, the module exports two functions. getStore is a thin wrapper over the store. registerReducer is the injection point described above.
Typing the store
// redux-utils/types.ts
import { NonwizardNamespaceShape } from "../data/nonwizard/types";
import { WizardNamespaceShape } from "../data/wizards/types";
import { Reducer } from "redux";
export const NONWIZARD_NAMESPACE_KEY = "NONWIZARD_NAMESPACE";
export const WIZARD_NAMESPACE_KEY = "WIZARD_NAMESPACE";
export type FullStoreShape = {
[NONWIZARD_NAMESPACE_KEY]: NonwizardNamespaceShape;
[WIZARD_NAMESPACE_KEY]: WizardNamespaceShape;
};
export type StoreShape = Partial<FullStoreShape>;
export type NamespaceKey = keyof StoreShape;
export type ReducerMap = Partial<
{[k in NamespaceKey]: Reducer<FullStoreShape[k]>}
>;
NonwizardNamespaceShape and WizardNamespaceShape are imported from elsewhere in the codebase. Because these are type-only imports, most build systems will not bundle them when building packages.
StoreShape and ReducerMap let every possible type be declared on the state object up front. Colocating the namespace keys in the types file makes key conflicts visible to developers. Both types are declared Partial, since a given namespace is not guaranteed to be on the store — so enforcement that a reducer was actually registered has to happen elsewhere, in the selector layer.
Enforcing registration through selectors
// redux-utils/selectors.ts
import { FullStoreShape, NamespaceKey, StoreShape } from "./types";
export function getStateAtNamespaceKey(
state: StoreShape,
namespace: T
): FullStoreShape[T] {
const namespaceState = state[namespace];
if (!namespaceState) {
throw new Error(
`Attempted to access state for an unregistered namespace at key ${namespace}`
);
}
// We need to explicitly say this is not undefined because TypeScript doesn't
// recognize the thrown error will prevent us from ever getting here.
return namespaceState!;
}
getStateAtNamespaceKey raises an error if you try to reach a namespace that has not been registered, and it should be the only way data is read. If registerReducer is called in the same part of the tree as <Provider />, the namespace will already be registered by the time a connect-ed component runs.
What product code looks like
//wizards.tsx
import * as React from "react";
import { connect, Provider } from "react-redux";
import { getStoreForWizardApp } from "./data/wizards/store";
import { Wizard } from "./data/wizards/types";
import { getWizards } from "./data/wizards/selectors";
export function WizardApp() {
return (
<Provider store={getStoreForWizardApp()}>
<ConnectedWizards />
</Provider>
);
}
function Wizard({ name, spells, hasWand }: Wizard) {
return (
<>
<span>Name: {name}</span>
<span>Parents: {parentsAlive ? "Alive" : "Dead"}</span>
<span>Learned Spells: {spells.map(spell => `${spell} `)}</span>
</>
);
}
type WizardsProps = { wizards: Wizard[] };
function Wizards({ wizards }: WizardsProps) {
return (
<>
{wizards.map(wizardProps => (
<Wizard {...wizardProps} />
))}
</>
);
}
const mapStateToProps = state => ({
wizards: getWizards(state)
});
const ConnectedWizards = connect(mapStateToProps)(
Wizards
);
This component connects with react-redux, using <Provider /> and connect, and renders a list of wizards along with the spells they know and their parents' status. The two pieces worth examining are getStoreForWizardApp and getWizards.
getStoreForWizardApp
// data/wizards/store.ts
import { getStore, registerReducer } from "../../redux-utils/store";
import { WIZARD_NAMESPACE_KEY } from "../../redux-utils/types";
import { once } from "lodash";
import wizardReducer from "./reducer";
export const getStoreForWizardApp = once(() => {
registerReducer({ [WIZARD_NAMESPACE_KEY]: wizardReducer });
return getStore();
});
Here registerReducer receives a map keyed by the Wizard namespace and paired with the Wizard reducer; passing the wrong key or the wrong reducer is a type error. The call is wrapped in lodash.once so the reducer is registered only once and the same store instance is always returned. This is not strictly required, but replaceReducer is an expensive no-op when called repeatedly.
getWizards
// data/wizards/selectors.ts
import { getStateAtNamespaceKey } from "../../redux-utils/selectors";
import { StoreShape, WIZARD_NAMESPACE_KEY } from "../../redux-utils/types";
import { mapValues } from "lodash";
import { Wizard } from "./types";
const getWizards = (state: StoreShape) => (
getStateAtNamespaceKey(state, WIZARD_NAMESPACE_KEY)
);
This one simply calls getStateAtNamespaceKey and returns the wizards.
Typing actions and reducers
// data/wizards/actions.ts
import { AnyAction } from "redux";
export type Wizard = {
name: string;
hasWand: boolean;
spells: string[];
};
export const enum WizardActionTypes {
LearnSpell = "WIZARD/LEARN_SPELL",
BreakWand = "WIZARD/BREAK_WAND"
}
export type WizardNamespaceShape = {
[id: string]: Wizard;
};
export interface LearnSpellAction extends AnyAction {
type: WizardActionTypes.LearnSpell;
payload: { id: string; spell: string };
}
export interface BreakWandAction extends AnyAction {
type: WizardActionTypes.BreakWand;
payload: { id: string };
}
export type WizardAction = LearnSpellAction | BreakWandAction;
There are two action types, LearnSpellAction and BreakWandAction, each with a strongly typed payload and a type drawn from a predetermined string enum. WizardAction is exported for use in the reducer.
// data/wizards/reducer.ts
import { WizardAction, WizardActionTypes, WizardNamespaceShape } from "./types";
const defaultState: WizardNamespaceShape = {
powerfulWizard: {
name: "Powerful Wizard",
hasWand: false,
spells: []
}
};
export default function reducer(
state: WizardNamespaceShape,
action: WizardAction
) {
switch (action.type) {
case WizardActionTypes.breakWand: {
const { id } = action.payload;
return {
...state,
[id]: {
...state[id],
hasWand: false
}
};
}
case WizardActionTypes.LearnSpell: {
const { id, spell } = action.payload;
return {
...state,
[id]: {
...state[id],
spells: [...state[id].spells, spell]
}
};
}
default: {
return state;
}
}
}
Because every member of WizardActionTypes defines its own type property, the switch statement narrows action.payload once the type is matched. Any invalid code in a branch is a compile error.
Hydrating the store with initial data
Initial state reaches the store through hydration: dispatching an action that sets the state. First, actions.ts gains the action shown below.
export const enum WizardActionTypes {
Hydrate = "WIZARD/HYDRATE",
LearnSpell = "WIZARD/LEARN_SPELL",
BreakWand = "WIZARD/BREAK_WAND"
}
export interface HydrateWizardsAction extends AnyAction {
type: WizardActionTypes.Hydrate;
payload: WizardNamespaceShape;
}
export type WizardAction =
| HydrateWizardsAction
| LearnSpellAction
| BreakWandAction;
Second, the reducer gains another switch case.
switch (action.type) {
case WizardActionTypes.Hydrate: {
return {
...action.payload
};
}
case WizardActionTypes.BreakWand: {
Third, an action creator is added.
// data/wizards/actions.ts
import { WizardActionTypes, WizardNamespaceShape } from "./types";
export function hydrateWizardNamespace(initialData: WizardNamespaceShape) {
return {
type: WizardActionTypes.Hydrate,
payload: initialData
};
}
// LearnSpell and BreakWand are exercises left up to the reader
Finally, the hydration action is dispatched from the store creation function.
import { getStore, registerReducer } from "../../redux-utils/store";
import { WIZARD_NAMESPACE_KEY } from "../../redux-utils/types";
import { once } from "lodash";
import wizardReducer from "./reducer";
import { WizardNamespaceShape } from "./types";
import { hydrateWizardNamespace } from "./actions";
export const getStoreForWizardApp = once(
(initialData?: WizardNamespaceShape) => {
registerReducer({ [WIZARD_NAMESPACE_KEY]: wizardReducer });
const store = getStore();
if (initialData) {
store.dispatch(hydrateWizardNamespace(initialData));
}
return store;
}
);
lodash.once is especially useful at this point, since the store is populated exactly one time.
The resulting split
At compile time the store is strongly typed and aware of the entire system. At runtime, reducers and their associated code can be split and loaded however the application needs.
Reference
Types
- NamespaceKey — a key for a reducer or namespace within the state object.
- ReducerMap — an object of all keys the store may carry and their matching reducers; partial, because no given namespace is guaranteed to be present.
- StoreShape — an object of all keys the store may carry and their state shapes; also partial for the same reason.
Functions
- hydrateWizardNamespace — product-layer function supplying initial state for the wizard namespace after its reducer is registered.
- getStoreForWizardApp — product-layer function that registers the "wizard" namespace in the store.
- getStateAtNamespaceKey — retrieves a namespace from the state object and fails fast when it is unregistered; the basis of selector type safety.
- registerReducer — injects a reducer into the store after page load, while restricting registration to known reducers at the typing layer.



