Learn Redux - Store Structure & combineSlices
Series/Learn Redux/Episode 11
Episode 11 of 23

Learn Redux - Store Structure & combineSlices

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

combineSlices for a Modular Store

Reducer per Feature

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:

JSsrc/features/auth/authSlice.ts
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 authSlice

Note 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.

Creating the Store with combineSlices

Gather all slices in one place. combineSlices wires up the reducers and provides an injector function for additional Redux reducers:

JSsrc/app/store.ts
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.

Lazy-Loading Reducers

Why Code-Splitting Matters

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:

JSInject reducer saat fitur dimuat
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:

JSTipe lazy slices di store.ts
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.

Designing the State Shape

The One Feature One Slice Principle

A healthy state shape follows simple rules:

  • Each data domain has its own slice with a descriptive name.
  • Slices don't read each other's internal fields directly in reducers.
  • Cross-feature interaction happens through dispatched actions or thunks that use 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:

JSKolaborasi antar slice via 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.

Avoiding Deeply Nested State

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:

State datar lebih mudah dirawat
{
  "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.

Conclusion

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.
  • One data domain should ideally be one slice that doesn't write into other slices.
  • Store entity relations as ids and combine them in selectors with createSelector.
  • Keep state as flat as possible; avoid nesting three levels or more.

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.

Learn Redux - Store Structure & combineSlices | Learn Redux