State Management in Next.js with Redux: Practical Journey

by Liben Hailu
25 mins read

Discover how to streamline state management in Next.js using Redux and RTK Query. Learn essential Redux concepts, integration with Next.js, and advanced techniques for optimized performance.


Redux State Management with Next.js App Router

Redux is both a pattern and a library for managing application state using events and actions. It serves as a central store for the application state, which can be accessed and utilized across the application. State updates occur in a predictable fashion.

Redux simplifies state management when there are numerous states or reusable states with complex update logic that are utilized throughout the application. Additionally, Redux is opinionated, meaning it makes most design decisions for you. If multiple people are working on the project, using Redux can help set a standard.

Terminology Used in Redux

  • Store

    The store holds the current application state, which can be accessed using the store.getState() function. The store is created by passing the reducers.

  • selectors

    Selectors are functions that are used to extract a piece of information from the state value.

  • Actions Actions are JavaScript objects with a type field. You can think of actions as events that describe what happened in the application. Usually, it is written in the format domain/eventName, where the domain is the feature or category to which the action belongs, and the eventName is the specific thing that happens. Example: todos/todoAdded where todos is the feature and todoAdded is the event that occurred. Actions can also have a payload field to pass additional information about the event that occurred to the reducer.

  • Action Creators Action creators are functions that create and return action objects, to eliminate the need to manually write an action object each time.

  • Reducers A reducer is a function that receives state and an action object, determining how to update the state and returning a new state.

  • Dispatch The Redux store has a method called dispatch. The only way to update the state is to call dispatch and pass in an action object. The store will run its reducers and save a new state.

Redux Toolkit

Redux Toolkit (RTK) is a recommended approach for writing Redux logic. It wraps around the core Redux package and provides common API methods and dependencies essential for building Redux applications.

Redux Toolkit with Next

First things first, create a new Next.js project.

pnpm create next-app@latest my-todos --typescript --tailwind --eslint

Now let's add the libraries for Redux.

pnpm add redux react-redux @reduxjs/toolkit

Redux is the core of Redux Toolkit, react-redux is used to connect Redux to the React application, and @reduxjs/toolkit is the easiest way to write Redux logic.

Let's create a store file.

import { configureStore } from '@reduxjs/toolkit'

export const makeStore = () => {
  return configureStore({
    reducer: {},
  })
}

Currently, our store has no reducers. Now, let's proceed to create the store provider component.

export default function StoreProvider({
  children,
}: {
  children: React.ReactNode
}) {
  const storeRef = useRef<AppStore>()
  if (!storeRef.current) {
    // Create the store instance the first time this renders
    storeRef.current = makeStore()
  }

  return <Provider store={storeRef.current}>{children}</Provider>
}

Now it is time to create a todo slice. Redux Slice is a collection of reducer logic and actions for a single feature of the application.

const initialState = [
  { id: '1', task: 'Write a blog post on Redux toolkit', completed: false },
  { id: '1', task: 'Buy Milk', completed: false },
]

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {}
})

export const selectAllTodos = (state: RootState) => state.todos;
export default todosSlice.reducer

The slice have a name which will be used to prefix the type of the action for example if we have todoAdded action the type of it will be todos/todoAdded, the initial state is the initial state of the store. Now lets add out todos reducer function to redux.

export const makeStore = () => {
  return configureStore({
    reducer: {
      todos: todosReducer,
    },
  })
}

Lets render out our todos, from our state.

export const TodosList = () => {
    const todos = useSelector(selectAllTodos)

    return (
        <section >
            <h2 className='font-semibold py-2'>Todos</h2>
            <div className="grid gap-4">
                {
                    todos.map(todo => (
                        <TodoItem key={todo.id} todo={todo} />
                    ))
                }
            </div>
        </section>
    )
}

Now you can see todos loaded from the store. we are using useSelector hook from react-redux to get the state. we could use useSelector((state)=>state.todos) However, this approach is not scalable. Let's say the shape of the object changed. If we implement it like this, we have to update all the places where we are using the selectors in this manner. To avoid this we can export a selector function from the slice and reuse when ever needed.

Adding New Todo

Let's add a todoAdded reducer function for our todo slice.

const todosSlice = createSlice({
    name: 'todos',
    initialState,
    reducers: {
        todoAdded: (state, action: PayloadAction<Todo>) => {
            state.push(action.payload)
        }
    }
})

export const {todoAdded} = todosSlice.actions
export const selectAllTodos = (state: RootState) => state.todos;

When we write the todoAdded reducer function, createSlice will automatically generate an action creator function with the same name. We can export that action creator and use it in our UI components to dispatch the action to the store.

Now we need to connect this todoAdded action creator with our component so that we can add new todo.

const dispatch = useDispatch()

const [task, setTask] = useState('')

function addTodo() {
    if (task) {
        dispatch(todoAdded({ task }))
        setTask("")
    }
}

On Submit we can call our addTodo function, and then dispatch our action if task is not empty. Our task has id how ever we don't have id here lets handle that next with prepare callback. preparing callback is very important if preparing logic is complex or the action is needed in multiple places.

Let's update the slice to incorporated prepare callback

const todosSlice = createSlice({
    name: 'todos',
    initialState,
    reducers: {
        todoAdded: {
            reducer(state, action: PayloadAction<Todo>) {
                state.push(action.payload);
            },
            prepare(task: string) {
                return {
                    payload: {
                        id: nanoid(),
                        task,
                        completed: false
                    }
                }
            },
        }
    }
})

Async Logic and Data Fetching

By default redux only knows how to synchronously dispatch actions, there are different async middlewares to work with async logic,the most common async middleware is redux-thunk, which can be found builtin inside redux toolkit configureStore the thunk middleware allows you to directly pass thunk function to the store.dispatch, the thunk function will be called with (dispatch, getState) arguments.

Now let's fetch our data form server we will be using this API https://jsonplaceholder.typicode.com/todos Lets' start with updating state for our todos slice.


const initialState = {
    status: 'idle',
    todos: [] as Todo[],
    error: null
}

const todosSlice = createSlice({
    name: 'todos',
    initialState,
    reducers: {
        todoAdded: {
            reducer(state, action: PayloadAction<Todo>) {
                state.todos.push(action.payload);
            },
            prepare(task: string) {
                return {
                    payload: {
                        id: nanoid(),
                        task,
                        completed: false
                    }
                }
            },
        }
    }
})

export const { todoAdded } = todosSlice.actions
export const selectAllTodos = (state: RootState) => state.todos.todos;

export default todosSlice.reducer

Redux toolkit's createAsyncThunk API creates actions automatically dispatch start/success/failure.

lets write out thunk function

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos')
    return response.json()
})

Now our reducer has to respond to action that is not defined inside our slice reducers in such cases we can use the slice extraReducers field.

const todosSlice = createSlice({
    name: 'todos',
    initialState,
    reducers: {
        ...
    },
    extraReducers(builder) {
        builder
            .addCase(fetchTodos.pending, (state) => {
                state.status = 'loading';
            })
            .addCase(fetchTodos.fulfilled, (state, action) => {
                state.status = 'succeeded';
                state.todos = state.todos.concat(action.payload);
            })
            .addCase(fetchTodos.rejected, (state, action) => {
                state.status = 'failed';
            });
    }

})

Now let's show the list of todos on our <TodoList/> component.

const todoStatus = useSelector(selectTodoStatus)
const dispatch = useDispatch()

useEffect(() => {
    if (todoStatus === 'idle') {
        dispatch(fetchTodos())
    }
}, [todoStatus, dispatch])

Now you can see the list of todos fetched from typicode API. We can also show loading, error states depending on out state status.

Normalizing State with createEntityAdapter API

Redux Toolkit's createEntityAdapter API provides a standardized way to store your data in a slice by taking a collection of items and putting them into the shape of { ids: [], entities: {} }.

createEntityAdapter has pre-built reducer function to handle common cases like get all items

Let's update todoSlice to use createEntityAdapter

const todosAdapter = createEntityAdapter({})

const initialState = todosAdapter.getInitialState({
    status: 'idle',
    error: null,
})

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos')
    return response.json()
})

const todosSlice = createSlice({
    name: 'todos',
    initialState,
    reducers: {
        ...
    },
    extraReducers(builder) {
        builder
            .addCase(fetchTodos.pending, (state) => {
                state.status = 'loading';
            })
            .addCase(fetchTodos.fulfilled, (state, action) => {
                state.status = 'succeeded';
                todosAdapter.upsertMany(state, action.payload)

            })
            .addCase(fetchTodos.rejected, (state, action) => {
                state.status = 'failed';
            });
    }

})


export const {
    selectAll: selectAllTodos,
} = todosAdapter.getSelectors((state: RootState) => state.todos);

Until now we have seen how to fetch data and cache with redux, using async thunks and dispatching actions with the result, now we will see RTK query for data fetching and caching.

RTK Query

RTK Query is data fetching and caching tool, designed to simplify the need to hand-write data fetching and caching logic. It is an optional addon included in redux toolkit. Now let's migrate from async logic to RTK Query

Let's start by creating apiSlice which handles managing data.

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

export const apiSlice = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
    endpoints: builder => ({
        getTodos: builder.query({
            query: () => '/todos'
        })
    })
})

export const { useGetTodosQuery } = apiSlice

When calling createApi, baseQuerty and endpoints fields are required, baseQuery is a function that handles fetching data, fetchBaseQuery is a wrapper around fetch, we can pass baseUrl as well as override request headers if needed. endpoints are set of operation for interacting with the server can be query or mutations. RTK query will generate react hooks automatically for every endpoint we define. these hooks naming convention, useEndpointNameEndpointType example: useGetTodosQuery

Now let's integrate the api slice to redux store

export const makeStore = () => {
  return configureStore({
    reducer: {
      [apiSlice.reducerPath]: apiSlice.reducer
    },
    middleware: getDefaultMiddleware =>
      getDefaultMiddleware().concat(apiSlice.middleware)
  })
}

The api slice generates a middleware that should be added to tha store to manage cache lifetime and expiration.

Now let's use the generated hooks in our <TodoList/> component.

const {
    data: todos,
    isLoading,
    isSuccess,
    isError,
    error
} = useGetTodosQuery()

if (isLoading) {
    return <p>Loading...</p>
}

return (
    <section >
        <h2 className='font-bold py-2'>Todos</h2>
        <div className="grid gap-4">
            {
                todos.map(todo => (
                    <TodoItem key={todo.id} todo={todo} />
                ))
            }
        </div>
    </section>
)

Now let's add addTodo mutation.

export const apiSlice = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
    endpoints: builder => ({
        ...
        addTodo: builder.mutation({
            query: (todo: Todo) => ({
                url: "/todos",
                method: "POST",
                body: todo
            })
        })
    })
})

export const { useGetTodosQuery, useAddTodoMutation } = apiSlice

Let's use it in our addTodo component.

export const AddTodo = () => {
    const [addTodo, { isLoading }] = useAddTodoMutation()

    const [task, setTask] = useState('')

    async function addTodoHandler() {
        if (task) {
            await addTodo({ title: task }).unwrap()
            setTask("")
        }
    }

    return (
        <>
            <div className="w-full flex-col gap-4">
                <Label htmlFor="task" className="mb-1">Task</Label>
                <Input placeholder="Task" id="task" value={task} onChange={e => setTask(e.target.value)} />
                <Button className="w-full justify-center my-2" type="button" aria-disabled={isLoading} onClick={addTodoHandler} size="sm">
                    Create Todo
                </Button>
            </div>
        </>
    )
}

Generally we want to update out todos after mutation has occurred. RTK Query lets us define relationships between queries and mutations to enable automatic data refetching, using "tags". A "tag" is a string or small object that lets you name certain types of data, and invalidate portions of the cache. When a cache tag is invalidated, RTK Query will automatically refetch the endpoints that were marked with that tag. Basic tag usage requires three pieces of information.

  • TagTypes: field in api slice, an array of string tag names.
  • providesTags: array in query endpoints, that provide tags for the cache.
  • invalidatesTags: array in mutation endpoints, listing a set of tags that will be invalidated when the mutation runs.

Let's add tags to our apiSlice

export const apiSlice = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
    tagTypes: ['Todo'],
    endpoints: builder => ({
        getTodos: builder.query({
            query: () => '/todos',
            providesTags: ['Todo']
        }),
        addTodo: builder.mutation({
            query: (todo: Todo) => ({
                url: "/todos",
                method: "POST",
                body: todo
            }),
            invalidatesTags: ['Todo']
        }),
        deleteTodo: builder.mutation({
            query: (id: string) => ({
                url: `/todos/${id}`,
                method: "DELETE",
            }),
            invalidatesTags: ['Todo']
        })
    })
})

Invalidating Specific Items

By default RTK query caches data, we might get inconsistent data for example after update, one way to fix this is to use invalidateTags but that will cause to all the todos to be refetch.

To solve this RTK Query lets us define specific tags, which let us be more selective in invalidating data. These specific tags look like {type: 'Todo', id: 123}.

providesTags field can accept a callback function that receives the result and arg, and returns an array. This allows us to create tag entries based on IDs of data that is being fetched. Similarly, invalidatesTags can be a callback as well.

Now let's update our slice in such a way

export const apiSlice = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
    tagTypes: ['Todo'],
    endpoints: builder => ({
        getTodos: builder.query({
            query: () => '/todos',
            providesTags: (result = [], error, arg) => [
                'Todo',
                ...result.map(({ id }: Todo) => ({ type: 'Todo', id }))
            ] // Provides Todo tag and {type: 'Todo', id} tag for each todo
        }),
        addTodo: builder.mutation({
            query: (todo: Todo) => ({
                url: "/todos",
                method: "POST",
                body: todo
            }),
            invalidatesTags: ['Todo'] // invalidates the general 'Todo' tag
        }),
        getTodo: builder.query({
            query: (id: string) => ({ url: `/todos/${id}` }),
            providesTags: (result, error, arg) => [{ type: 'Todo', id: arg }] // provides {type: 'Todo', id} for a specific todo
        }),
        updateTodo: builder.mutation({
            query: (todo: Todo) => ({
                url: `/todos/${todo.id}`,
                method: "PATCH",
                body: todo
            }),
            invalidatesTags: (result, error, arg) => [{ type: 'Todo', id: arg.id }] //  invalidates the specific {type: 'Todo', id} tag.
        }),
        deleteTodo: builder.mutation({
            query: (id: string) => ({
                url: `/todos/${id}`,
                method: "DELETE",
            }),
            invalidatesTags: ['Todo'] // invalidates the general 'Todo' tag
        })
    })
})

Source code can be found here.