This episode covers store architecture for large applications: combineSlices to combine modular reducers, lazy-loading reducers via store.inject, the rootReducer pattern, and designing healthy per-feature state shapes and cross-slice collaboration.

When an app has only one or two features, keeping all reducers in a single file feels reasonable. But as features grow — auth, cart, notifications, users — one giant store becomes hard to maintain. Episode 11 covers proper store structure: splitting reducers per feature, combining them modularly, and allowing features to load later through lazy loading.
Redux Toolkit provides combineSlices as the modern replacement for the classic combineReducers. It allows reducers to be injected into the store at runtime — the foundation of Redux code-splitting in framework applications. We'll also look at how to design state shapes so each feature is self-contained yet able to collaborate.
The feature-based folders pattern splits code per feature: each feature owns its slice, selectors, and components. The store only combines the slices that have been declared:
import { createSlice } from "@reduxjs/toolkit"
interface AuthState {
user: string | null
token: string | null
}
const authSlice = createSlice({
name: "auth",
initialState: { user: null, token: null } satisfies AuthState,
reducers: {
loggedIn: (state, action) => {
state.user = action.payload.user
state.token = action.payload.token
},
loggedOut: (state) => {
state.user = null
state.token = null
},
},
})
export const { loggedIn, loggedOut } = authSlice.actions
export default authSliceNote that the reducer is written as a slice object, not a reducer function. combineSlices needs slice objects so it knows the state keys: the slice name auth automatically becomes the key in the store.
Gather all slices in one place. combineSlices wires up the reducers and provides an injector function for additional Redux reducers:
import { combineSlices, configureStore } from "@reduxjs/toolkit"
import { authSlice } from "../features/auth/authSlice"
import { postsSlice } from "../features/posts/postsSlice"
export const rootReducer = combineSlices(authSlice, postsSlice)
export type RootState = ReturnType<typeof rootReducer>
export const store = configureStore({
reducer: rootReducer,
})Now the state is shaped { auth: ..., posts: ... }. RootState is derived directly from rootReducer, so every time a slice is added to combineSlices, the state type updates automatically.
An admin feature that's rarely opened doesn't need to load alongside the main page. With store.inject, a new feature's reducer only enters the store when that feature is actually loaded:
import { createSlice } from "@reduxjs/toolkit"
import { store } from "../../app/store"
export const adminSlice = createSlice({
name: "admin",
initialState: { logs: [] as string[] },
reducers: {
logAdded: (state, action) => {
state.logs.push(action.payload)
},
},
})
store.inject(adminSlice)store.inject(slice) does three things at once: adds the reducer to the combination, updates RootState (at runtime), and marks the reducer as already registered so a second call doesn't create a duplicate. CombineSlices also supports the withLazyLoadedSlices option to register the types of reducers that aren't loaded yet at startup:
import { combineSlices } from "@reduxjs/toolkit"
const rootReducer = combineSlices(authSlice, postsSlice)
.withLazyLoadedSlices<{ admin: typeof adminSlice }>()With the declaration above, TypeScript understands that the admin key doesn't exist yet but will be injected later — a missed injection produces a compile error rather than silently breaking at runtime.
A healthy state shape follows simple rules:
getState.An example of entity relations: a posts list stores authorId, not a copy of the author object. A component displaying the author name combines selectPostById and selectUserById through createSelector:
import { createSelector } from "@reduxjs/toolkit"
import { selectPostById } from "../posts/postsSelectors"
import { selectUserById } from "../users/usersSelectors"
export const selectPostWithAuthor = (postId: number) =>
createSelector(
[selectPostById(postId), selectUserById],
(post, users) => {
if (!post) return null
return { ...post, author: users[post.authorId] }
},
)Both selectors read state from different slices, but the composition happens at the selector level — not by mutating another slice from inside a reducer. State stays modular while cross-feature collaboration remains possible.
Tip
Don't make one slice write into another slice's state. If two features must stay in sync, pick one approach: the same action handled by two reducers (action-sharing pattern), or coordinated dispatch inside a thunk.
Keep state as flat as possible. A three-level structure like cart.items.meta.source makes updates, normalization, and debugging harder. Split it into separate slices and connect them by id:
{
"cart": { "items": [{ "productId": 9, "qty": 2 }] },
"products": { "ids": [9], "entities": { "9": { "id": 9, "name": "Meja" } } }
}cart stores only productId and qty; product details live in the products slice. Updating a product's price never touches the cart at all — one of the advantages of normalization that we also covered in episode 10.
combineSlices changes how the store is built: per-feature reducers are gathered declaratively, lazy loading lets heavy features load only when needed, and withLazyLoadedSlices keeps type safety during the injection process. Designing a flat, modular state shape keeps the store easy to audit even as the app keeps growing.
Key takeaways:
combineSlices combines slices and derives RootState automatically.store.inject(slice) adds a reducer at runtime for lazy loading.withLazyLoadedSlices declares the types of reducers injected later.createSelector.In the next episode, episode 12 covers middleware — Redux Thunk, when to write a manual thunk versus using createAsyncThunk, and createListenerMiddleware for responding to actions centrally without polluting reducers.