Skip to content

Migrating from React Redux to React Query

October 19, 2021Robert Hofmann7 min read

React Query

React Redux is an extremely popular package, receiving about 4.5 million weekly downloads. Another popular package, Redux Saga, is a Redux side effect manager — it alone receives over 900k weekly downloads. The combination of React Redux with either Redux Saga or Redux Thunk is one of the most common additions to React projects.

However, over the last year or so, React Query has been gaining popularity quickly as an alternative to React Redux. Last year, React Query had only about 120k weekly downloads, but as of October 2021, React Query downloads are nearly six times more frequent, at nearly 700k downloads per week.

React Query has some distinct advantages over React Redux for storing server state.

A major advantage of React Query is that it is far simpler to write than React Redux. In React Redux, an operation to optimistically update a field requires three actions (request, success, and failure), three reducers (request, success, and failure), one middleware, and a selector to access the data. In React Query however, the equivalent setup is significantly simpler as it only requires one mutation (which consists of four functions) and one query.

Another advantage of React Query is that it works more smoothly with navigation tools built into many IDEs. With React Query, you can use “Go To Definition” to more easily reach your mutation whereas with Redux Saga, you must search the entire codebase for uses of the action.

On any existing project, making the switch to use a new package can be a daunting task. Today, to help make this task more manageable, we will be looking at an example where an application using React Redux with Redux Saga will be migrated to React Query.

Our Initial Project

To start, let's take a look at a basic optimistic Redux setup with three reducers, three actions, a selector, and a saga.

example site without name entered example site with name entered

In our example, we have a React website where a user can input their name and their name is displayed on the page. A selector is used on the React page to display the user’s inputted name. When a user inputs their name, a request action is dispatched that causes a reducer and a saga to run. The reducer optimistically updates the store with the new value of the name. Meanwhile, the saga makes a call to the backend to save the user’s name.

If the backend returns an HTTP success, the saga dispatches a success action. The success action causes a reducer to run which updates the value of the name in the store.

If the backend returns an HTTP failure, the saga dispatches a failure action. The failure action causes a reducer to run which changes the value in the store back to its value before the optimistic update.

NameComponent.tsx

import React, {
  ChangeEvent,
  FormEvent,
  FunctionComponent,
  useCallback,
  useEffect,
  useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import { selectName, updateNameRequest } from "../redux/slice";

export const NameComponent: FunctionComponent = () => {
  const name = useSelector(selectName);
  const [localName, setLocalName] = useState<string>("");
  const dispatch = useDispatch();

  useEffect(() => {
    if (name) setLocalName(name);
  }, [name]);

  const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setLocalName(event.target.value);
  }, []);

  const handleSubmit = useCallback(
    (event: FormEvent) => {
      dispatch(updateNameRequest({ name: localName }));
      event.preventDefault();
    },
    [dispatch, localName]
  );

  return (
    <div>
      <p>{`Your name is ${name ?? "unknown to me"}.`}</p>
      <form onSubmit={handleSubmit}>
        <label>
          {`Enter name: `}
          <input type="text" onChange={handleChange} />
        </label>
        <input type="submit" value="Update Name" />
      </form>
    </div>
  );
};

slice.ts

import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { RootState } from "..";

export interface NameState {
  name: string | null;
  error: string | null;
}

export const initialState: NameState = {
  name: null,
  error: null,
};

export const nameSlice = createSlice({
  name: "name",
  initialState,
  reducers: {
    updateNameRequest: (state, action: PayloadAction<{ name: string }>) => ({
      ...state,
      ...action.payload,
    }),
    updateNameSuccess: (state, action: PayloadAction<{ name: string }>) => ({
      ...state,
      ...action.payload,
    }),
    updateNameFailure: (
      state,
      action: PayloadAction<{ error: string; name: string }>
    ) => ({
      ...state,
      ...action.payload,
    }),
  },
});

export const selectName = (state: RootState) => state.name;

const { actions, reducer } = nameSlice;

export const { updateNameRequest, updateNameSuccess, updateNameFailure } =
  actions;
export type NameAction =
  | typeof updateNameRequest
  | typeof updateNameSuccess
  | typeof updateNameFailure;
export { reducer as nameReducer };

sagas.ts

import { ActionType } from "typesafe-actions";
import { call, put, select, takeEvery } from "redux-saga/effects";
import {
  selectName,
  updateNameFailure,
  updateNameRequest,
  updateNameSuccess,
} from "./slice";
import { updateName } from "../api/api";

function* updateNameSaga(action: ActionType<typeof updateNameRequest>) {
  const { name } = action.payload;
  const oldName: string = yield select(selectName);

  try {
    yield call(updateName, name);
    yield put(updateNameSuccess(action.payload));
  } catch (error) {
    yield put(
      updateNameFailure({ error: (error as Error).message, name: oldName })
    );
  }
}

export function* nameSagas() {
  yield takeEvery(updateNameRequest, updateNameSaga);
}

store.ts

import { applyMiddleware, createStore } from "@reduxjs/toolkit";
import createSagaMiddleware from "redux-saga";
import { nameSagas } from "../redux/sagas";
import { nameReducer } from "../redux/slice";

const sagaMiddleware = createSagaMiddleware();

export default function configureStore() {
  const store = createStore(nameReducer, applyMiddleware(sagaMiddleware));

  sagaMiddleware.run(nameSagas);
  return { store };
}

Converting the Redux Store to React Query

The first step of our conversion is to create a query to replace the selector and the store. Below is a custom React hook that contains a useQuery to store the user’s name. In this example, getName is a call to the backend to get the name stored in the database.

import { getName } from "../api/api";

export const getNameQuery = () => ({
  queryKey: ["name"],
  queryFn: getName,
});

Converting the Saga to a Mutation

The next step of our conversion is to create an optimistic mutation to replace the saga, reducers, and Redux actions. Below is an optimistic mutation in a custom React hook that optimistically updates the query and invalidates the query after the update.

import { useMutation, useQueryClient } from "react-query";
import { updateName } from "../api/api";

export const useEditName = () => {
  const qc = useQueryClient();
  return useMutation<void, unknown, { newName: string }, string>(
    async ({ newName }) => {
      await updateName(newName);
    },
    {
      onMutate: async ({ newName }) => {
        await qc.cancelQueries("name");

        const currentName: string | undefined = qc.getQueryData("name");
        qc.setQueryData("name", newName);

        return currentName;
      },
      onError: (_error, _variables, context) => {
        if (context) qc.setQueryData("name", context);
      },
      onSettled: async () => {
        qc.invalidateQueries("name");
      },
    }
  );
};

The mutation function in this case calls the backend with updateName to change the name stored in the database.

Before the mutation function, Redux Query executes onMutate. In this case, we cancel the query that we are updating, get the current value for the query, and update the value for the query with the new value. We then return the old value for the query so we can revert if our optimism does not pay off.

If the mutation function throws an error, Redux Query executes onError. All we need to do is set the query to the context parameter if the backend call fails.

Lastly, Redux Query executes onSettled, which simply invalidates the query so Redux Query knows that the data is stale. This forces Redux Query to get the name fresh from the backend.

Final Project

The new query and mutation can be used in the following way in the React component. In the end, we will have a React Query setup with an optimistic mutation. Just two hooks replace the original React Redux and Redux Saga setup.

name.ts

import { useMutation, useQueryClient } from "react-query";
import { getName, updateName } from "../api/api";

export const getNameQuery = () => ({
  queryKey: ["name"],
  queryFn: getName,
});

export const useEditName = () => {
  const qc = useQueryClient();
  return useMutation<void, unknown, { newName: string }, string>(
    async ({ newName }) => {
      await updateName(newName);
    },
    {
      onMutate: async ({ newName }) => {
        await qc.cancelQueries("name");

        const currentName: string | undefined = qc.getQueryData("name");
        qc.setQueryData("name", newName);

        return currentName;
      },
      onError: (_error, _variables, context) => {
        if (context) qc.setQueryData("name", context);
      },
      onSettled: async () => {
        qc.invalidateQueries("name");
      },
    }
  );
};

NameComponent.tsx

import React, {
  ChangeEvent,
  FormEvent,
  FunctionComponent,
  useCallback,
  useEffect,
  useState,
} from "react";
import { useQuery } from "react-query";
import { getNameQuery, useEditName } from "../dataLayer/name";

export const NameComponent: FunctionComponent = () => {
  const editNameMutation = useEditName();
  const { data: name } = useQuery(getNameQuery());
  const [localName, setLocalName] = useState<string>("");

  useEffect(() => {
    if (name) setLocalName(name);
  }, [name]);

  const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setLocalName(event.target.value);
  }, []);

  const handleSubmit = useCallback(
    (event: FormEvent) => {
      editNameMutation.mutate({ newName: localName });
      event.preventDefault();
    },
    [editNameMutation, localName]
  );

  return (
    <div>
      <p>{`Your name is ${name ?? "unknown to me"}.`}</p>
      <form onSubmit={handleSubmit}>
        <label>
          {`Enter name: `}
          <input type="text" onChange={handleChange} />
        </label>
        <input type="submit" value="Update Name" />
      </form>
    </div>
  );
};

The last step for this setup is wrapping the application in a QueryClientProvider.

App.tsx

import React from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import "./App.css";
import { NameComponent } from "./components/NameComponent";

const queryClient = new QueryClient();

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <QueryClientProvider client={queryClient}>
          <NameComponent />
        </QueryClientProvider>
      </header>
    </div>
  );
}

export default App;

React Query is quicker to write, easier to navigate, and, now that you know how to use it, an alternative to storing and updating server state with React Redux with Redux Saga or Redux Thunk.