Preloading Redux store state in component tests
This page is part of the state injection section of the component testing fundamentals guide, which covers giving a component the exact state a scenario needs.
The specific problem: a connected component reads its data from a Redux store. Your test needs a cart with three items in it, and the only way you know to get there is to dispatch three addItem actions before asserting.
Problem statement
Reaching a state by replaying the actions that produce it makes the test depend on the whole action pipeline. The setup is long, it re-tests reducers that already have their own tests, and when addItem’s payload shape changes, every component test that used it to reach a state breaks — none of which were about addItem.
The symptom is a test file whose arrange block is longer than its assertions, and a suite where one reducer change produces two dozen unrelated failures.
Root cause
Redux Toolkit’s configureStore accepts preloadedState, which sets the store’s initial value directly. Most test suites do not use it, because the store is usually created once in store.ts and imported — and an imported singleton has no place to accept per-test state.
That singleton is also the second half of the problem. It is created at module load and shared by every test in the file, so a dispatch in one test is visible in the next. Both problems have the same fix: build the store inside the test, from state you declare.
Minimal reproduction
// the app's store, created once at import time
import { store } from '../app/store';
it('renders three line items', async () => {
store.dispatch(addItem({ sku: 'A1', qty: 1 })); // re-testing the reducer
store.dispatch(addItem({ sku: 'B2', qty: 2 }));
store.dispatch(addItem({ sku: 'C3', qty: 1 }));
render(<Provider store={store}><CartSummary /></Provider>);
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
it('renders an empty cart', () => {
render(<Provider store={store}><CartSummary /></Provider>);
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument(); // fails
});
Step-by-step fix
1. Export a store factory, not a store
// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import cart from '../features/cart/cartSlice';
export const makeStore = (preloadedState?: Partial<RootState>) =>
configureStore({ reducer: { cart }, preloadedState });
// the app still gets one instance
export const store = makeStore();
export type RootState = ReturnType<typeof store.getState>;
What this does: production keeps a single store, and tests gain a way to build fresh ones with whatever state they need.
2. Preload the state the scenario describes
const threeItems = {
cart: {
items: [
{ sku: 'A1', name: 'Keyboard', qty: 1, price: 89 },
{ sku: 'B2', name: 'Mouse', qty: 2, price: 25 },
{ sku: 'C3', name: 'Cable', qty: 1, price: 9 },
],
status: 'idle' as const,
},
};
What this does: states the scenario as data. A reader sees the cart’s contents without mentally executing three reducers.
3. Render through a wrapper that builds the store
function renderWithStore(ui: ReactElement, preloadedState?: Partial<RootState>) {
const store = makeStore(preloadedState);
const view = render(<Provider store={store}>{ui}</Provider>);
return { ...view, store }; // hand the store back for assertions
}
it('renders three line items', () => {
renderWithStore(<CartSummary />, threeItems);
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
What this does: every render gets its own store, so no test can observe another’s dispatches — and returning the store lets a test assert on state afterwards.
4. Assert on resulting state, not on dispatched actions
it('dispatches removeItem on click', async () => {
const { store } = renderWithStore(<CartSummary />, threeItems);
await userEvent.click(screen.getAllByRole('button', { name: /remove/i })[0]);
// assert the outcome, which survives a rename of the action
expect(store.getState().cart.items).toHaveLength(2);
});
What this does: couples the test to behaviour rather than to the internal action name, so an action rename does not break a component test.
Verification
Run the file with shuffling to prove the tests are genuinely independent:
vitest run src/CartSummary.test.tsx --sequence.shuffle
Every ordering should pass. A failure on some seeds means a store is still being shared — usually a renderWithStore call hoisted out of the test body.
Edge cases and caveats
-
Partial preloaded state.
preloadedStateis merged at the top level only, so supplying{ cart: { items: [] } }replaces the whole cart slice rather than merging into its initial value. Include every field the slice’s reducers expect, or build it from the slice’s own initial state with a spread. -
Middleware in tests. Removing all middleware to silence a warning also removes serialisability checks that catch real bugs. Customise
getDefaultMiddleware— disabling one check for one slice — rather than replacing the array. -
Zustand and Jotai. The same rule applies with a different API: export a factory, call it per test. Zustand’s default store is a module singleton, so a test importing it directly inherits every previous test’s mutations exactly as Redux does.
Naming the states instead of inlining them
Preloaded state written inline in each test reads well the first time and becomes a maintenance problem by the tenth. A dozen tests each carrying their own twelve-line cart object means a schema change touches twelve places, and no two of them describe quite the same cart.
The fix is the same one that works for network fixtures: name the states and export them. A cartStates.ts module exporting emptyCart, threeItems, overLimit, and pendingCheckout gives the suite a shared vocabulary, and a test then reads renderWithStore(<CartSummary />, overLimit) — which says what the scenario is rather than spelling out the data that happens to produce it.
Two rules keep that module useful. Type it against the slice’s own state type, so a field added to the slice fails compilation in the fixture file rather than producing a subtly wrong render. And export factories where a test needs a variation: makeCart({ items: 41 }) produces a fresh object, whereas a shared constant that one test mutates becomes exactly the shared mutable state the store factory was introduced to remove.
The same module then serves more than the unit tests. A Storybook decorator can build its store from overLimit to render that state as a browsable story, and a visual baseline can be captured from it. One named state, three consumers, and no chance of the three drifting apart — which is the practical argument for treating fixtures as a first-class part of the codebase rather than as test scaffolding.
FAQ
Is preloading state cheating, compared with dispatching actions?
No — it is separating two questions. Whether addItem produces the right state is a reducer test, and it belongs in a fast unit test of the reducer alone. Whether the component renders a three-item cart correctly is a component test, and preloading gets it to the starting line without re-testing the reducer. Dispatching to reach a state couples the second question to the first, so a change to either breaks both.
How do I test the loading and error states of a thunk?
Preload the state each phase produces, rather than running the thunk. A slice with status: 'pending' renders the spinner; status: 'rejected' with an error message renders the error card. The thunk’s own transitions are a reducer test. This also removes the need to mock the network in a component test that is only about rendering.
Should the render wrapper be shared across the codebase?
Yes. One renderWithStore — or a broader renderWithProviders that also supplies the router, query client and theme — keeps the provider tree in one place, so adding a provider updates every test at once. Export the fixtures it accepts alongside it, so a scenario like an over-limit cart is named once and reused rather than re-typed per file.
Can I preload state for a slice that uses RTK Query?
Yes, though it is usually the wrong level. RTK Query keeps its cache in its own slice with an internal shape that is awkward to hand-write and changes between versions. Mocking the HTTP the query issues — with MSW, at the wire — gives you the same starting state through the library’s real code path, and survives version upgrades that a hand-written cache object would not.
Related
- State Injection — the parent section on supplying known state to a component under test
- Managing Component State During Automated Tests — the reset-and-flush sequence every deterministic test follows
- Isolation Principles — the boundaries that stop injected state leaking between tests
- Mock Boundaries — intercepting the network so injected state is the only input