Learn Redux - Performance & Memoized Selectors
Series/Learn Redux/Episode 13
Episode 13 of 23

Learn Redux - Performance & Memoized Selectors

This episode covers Redux performance: reselect and createSelector for memoization, composing derived state, avoiding excessive re-renders with shallowEqual, splitting granular state, and profiling techniques using Redux DevTools.

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

Introduction

Redux offers good performance by default, but how you write selectors determines how often components re-render. A selector that returns a new array or object on every call makes useSelector think the state changed — even when the data is identical. Episode 13 covers how to control this with reselect and createSelector.

The goal is simple: make sure components only render when the data they read actually changes. We'll learn memoization, selector composition, shallow equality, granular state structure, and how to verify all of it with Redux DevTools.

Understanding Selector Memoization

The Problem of Selectors That Create New References

Look at a selector that filters on every call:

JSSelector yang selalu membuat array baru
import { createSelector } from "@reduxjs/toolkit"
 
export const selectVisiblePosts = createSelector(
  [(state) => state.posts.items],
  (items) => items.filter((post) => post.published),
)

Without createSelector, the items.filter(...) code produces a new array on every call. useSelector compares results with ===; because the reference differs, the component re-renders on every action — even actions that don't touch posts at all.

How createSelector Works

createSelector remembers the last inputs and its result. It only evaluates the combination function when one of the input selectors returns a different value:

JSAnatomi createSelector
import { createSelector } from "@reduxjs/toolkit"
 
const selectItems = (state) => state.posts.items
const selectKeyword = (state) => state.posts.keyword
 
export const selectFilteredPosts = createSelector(
  [selectItems, selectKeyword],
  (items, keyword) =>
    items.filter((post) => post.title.toLowerCase().includes(keyword)),
)

As long as items and keyword don't change, the filter result is cached and reused by reference. A component using selectFilteredPosts only re-renders when the keyword or the items actually change.

Composing Derived State

Selectors from Selectors

Memoized selectors can be composed in layers. The result of one selector becomes the input of the next, so expensive computations aren't repeated:

JSKomposisi derived state
import { createSelector } from "@reduxjs/toolkit"
 
const selectAllUsers = (state) => state.users.items
const selectActiveUserId = (state) => state.users.activeId
 
export const selectActiveUser = createSelector(
  [selectAllUsers, selectActiveUserId],
  (users, activeId) => users.find((u) => u.id === activeId),
)
 
export const selectActiveUserStats = createSelector(
  [selectActiveUser],
  (user) => {
    if (!user) return null
    return { postCount: user.postIds.length, followerCount: user.followers }
  },
)

selectActiveUserStats is only recomputed when selectActiveUser returns a new reference — that is, when activeId or the users list changes. This composition keeps performance linear with the amount of data that actually changes.

Selecting Minimal Data

The bigger the slice a selector pulls, the more components re-render with it. Select only the fields the component needs:

Selector minimal di komponen
import { useAppSelector } from "../../app/hooks"
 
export function UserName() {
  const name = useAppSelector((state) => state.users.items[0]?.name)
  return <p>{name}</p>
}

useAppSelector compares results with ===. Returning state.users.items[0]?.name (a string) means the component only re-renders when the first user's name changes — not every time other users are added.

shallowEqual and Granular State

Comparing Collections with shallowEqual

Sometimes a selector must return an object or array assembled from several fields. shallowEqual compares one level of properties so a change in one key doesn't trigger a re-render just because the object is new:

JSMenggunakan shallowEqual
import { useAppSelector } from "../../app/hooks"
import { shallowEqual } from "react-redux"
 
export function ProfileHeader() {
  const profile = useAppSelector(
    (state) => ({
      name: state.users.items[0]?.name,
      avatar: state.users.items[0]?.avatar,
    }),
    shallowEqual,
  )
  return <header>{profile.name}</header>
}

Without shallowEqual, a new object on every call makes the component render constantly. With shallowEqual, the component only re-renders when name or avatar actually changes value.

Splitting Granular State

Designing granular state helps selectors work more efficiently. Keep data that changes at different frequencies in separate keys:

State granular
{
  "posts": {
    "items": [ ... ],
    "editingId": null,
    "filters": { "keyword": "", "sortBy": "newest" }
  }
}

Typing in the search box only changes filters.keyword, so selectors that read posts.items keep using their memoized result. Keeping all data in one big array would mark the whole array as changed on every update.

Profiling with Redux DevTools

Reading the Action Trace and State Changes

Redux DevTools provides useful information for finding the source of re-renders:

Install ekstensi DevTools di browser
npm view @reduxjs/toolkit version

Pay attention to three things in the Action tab:

  • State diff: which fields changed per action. If typing a single letter changes many large fields, the slice is too coarse-grained.
  • Trace: the code that dispatched the action, useful for tracing the cause of unexpected changes.
  • Time-travel: jump to a specific state to prove whether a component renders because of that change.

Proving Re-renders

To verify the results of your optimization, log inside the render function:

JSDebug render komponen
export function PostList() {
  const posts = useAppSelector(selectFilteredPosts)
  console.log("PostList render", posts.length)
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}

If the console.log fires when an irrelevant action is dispatched, your selectors aren't memoized yet. If it only fires when the read data changes, the optimization worked.

Warning

Memoization isn't magic: the cached result is reset every time an input changes, and createSelector still calls the input selectors on every evaluation. Don't recompute something heavy inside the input selectors themselves.

Conclusion

Redux performance is mostly determined by selectors. createSelector provides memoization for derived state, selector composition prevents repeated computation, shallowEqual handles partially-read collections, and granular state reduces the scope of re-renders. With profiles read from Redux DevTools, you can prove that every optimization actually works.

Key takeaways:

  • createSelector caches results until one of the input selectors changes.
  • Compose selectors so expensive computations only run when needed.
  • Select the minimal data a component actually reads.
  • shallowEqual compares one-level properties for objects and arrays.
  • Split state per domain so small changes don't trigger wide re-renders.
  • Use state diff and trace in Redux DevTools to verify performance.

In the next episode, episode 14 takes Redux to server rendering — you'll create a per-request store in the Next.js App Router, use Provider for SSR, hydrate state, and integrate RTK Query with Server Components.

Learn Redux - Performance & Memoized Selectors | Learn Redux