Redux: Predictable State Container (RTK + RTK Query)

Medium

Redux is most valuable when you need a shared state architecture that remains understandable as the app grows. Modern Redux means Redux Toolkit (RTK) everywhere, and often RTK Query for server data.

Interviewers Care More About

  • choosing Redux for the right reasons
  • keeping state normalized
  • selector-driven performance
  • predictable async flows

Quick Decision Guide

Use Redux when: many screens share state, complex workflows, strong debugging + team consistency.

Use RTK Query when: you’re managing server data (fetching, caching, invalidation).

### Quick Setup (Redux Toolkit)

1) Create slice

import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1 },
    addBy: (state, action) => { state.value += action.payload }
  }
})

export const { increment, addBy } = counterSlice.actions
export default counterSlice.reducer

2) Configure store

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'

export const store = configureStore({
  reducer: { counter: counterReducer }
})

3) Use in React

import { Provider, useDispatch, useSelector } from 'react-redux'

const count = useSelector((s) => s.counter.value)
const dispatch = useDispatch()

dispatch(increment())

### RTK Query (recommended for server data)

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (build) => ({
    getPosts: build.query({
      query: () => '/posts',
      providesTags: (result = []) => [
        'Post',
        ...result.map((p) => ({ type: 'Post', id: p.id }))
      ]
    }),
    updatePost: build.mutation({
      query: (body) => ({ url: `/posts/${body.id}`, method: 'PUT', body }),
      invalidatesTags: (r, e, body) => [{ type: 'Post', id: body.id }]
    })
  })
})

Big idea: Redux Toolkit for app state; RTK Query for server cache; selectors for performance.

Mental Model & Architecture

Redux Mental Model

Redux is a single state tree updated by dispatching actions.

Flow: UI → dispatch(action) → reducer computes nextState → subscribers re-render.

What Redux Gives You

predictable updates (easy to reason about)
centralized debugging (DevTools action history)
enforceable patterns across a team

Common Mistake

Using Redux for everything (especially server data) leads to too much boilerplate.

Rule:

UI/client app state → slices
server cache → RTK Query (or dedicated data library)

Redux Toolkit (RTK) Patterns

Slices (state + reducers + actions)

RTK uses Immer, so reducers can "mutate" draft state safely.

import { createSlice } from '@reduxjs/toolkit'

const uiSlice = createSlice({
  name: 'ui',
  initialState: { modalOpen: false, toast: null },
  reducers: {
    openModal: (s) => { s.modalOpen = true },
    closeModal: (s) => { s.modalOpen = false },
    showToast: (s, a) => { s.toast = a.payload }
  }
})

Async (Two recommended options)

1) RTK Query for server data (preferred)

2) createAsyncThunk for workflows not tied to API caching

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'

export const fetchUser = createAsyncThunk('user/fetch', async (id) => {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('Request failed')
  return res.json()
})

const userSlice = createSlice({
  name: 'user',
  initialState: { entity: null, status: 'idle', error: null },
  reducers: {},
  extraReducers: (b) => {
    b.addCase(fetchUser.pending, (s) => { s.status = 'loading'; s.error = null })
     .addCase(fetchUser.fulfilled, (s, a) => { s.status = 'succeeded'; s.entity = a.payload })
     .addCase(fetchUser.rejected, (s, a) => { s.status = 'failed'; s.error = a.error.message })
  }
})

Performance: Normalization + Selectors (Most Important)

Normalize State Shape

Avoid deep nesting. Store entities by id.

// ✅ normalized
{
  users: { byId: { '1': { id:'1', name:'A' } }, allIds: ['1'] },
  posts: { byId: { '10': { id:'10', userId:'1' } }, allIds: ['10'] }
}

Select minimal state in components

Anti-pattern: selecting big objects causes rerenders.

// ❌ rerenders more than needed
const userState = useSelector((s) => s.user)

// ✅ minimal selection
const userName = useSelector((s) => s.user.entity?.name)

Derived data: memoize with reselect

import { createSelector } from '@reduxjs/toolkit'

const selectPostsById = (s) => s.posts.byId
const selectFilter = (s) => s.ui.filter

export const selectFilteredPosts = createSelector(
  [selectPostsById, selectFilter],
  (byId, filter) => Object.values(byId).filter((p) => p.category === filter)
)

Rule of thumb

store normalized entities
keep reducers simple
use selectors for computed views
don’t compute expensive arrays in components on every render

RTK Query: Server State Done Right

Why RTK Query

request deduping
caching
invalidation
loading/error states
less boilerplate than hand-written thunks

Pattern: tags for invalidation

providesTags for queries
invalidatesTags for mutations

This keeps UI consistent without manual refetch logic.

Next.js Notes (App Router)

Next.js App Router Notes

Redux store is client-side state.
In App Router, keep Redux setup in a Client Component boundary.
Prefer server data via server components / server actions; use RTK Query in client when needed.
Avoid creating multiple stores unintentionally across renders.

Tip: if you need per-request server state, don't try to "SSR" a global Redux store blindly; design explicit hydration boundaries.

Key Takeaways

1Modern Redux = Redux Toolkit + (often) RTK Query for server state.
2Redux is best for complex shared state and team-scale consistency, not small apps.
3Performance comes from selector discipline + normalized state (entities by id).
4Use RTK Query for caching/invalidation instead of storing server cache manually in slices.
5Prefer createAsyncThunk for workflows; avoid ad-hoc async patterns scattered across components.
6In Next.js App Router, Redux is client state—use clear hydration boundaries.