Skip to content

Data Fetching

There are a few different ways to fetch data from a server in React using a Redux store. One note is that we should never make API calls within our reducers.

React Router

React Router is a viable option for smaller projects that do not require larger state management. This can be accomplished by using the loader property on a route which can be used to fetch data.

ts
// function for executing the query and returning the results
// ex. api/queries/searchPackages.ts
export interface PackageSummary {
  name: string;
  version: string;
  description: string;
  keywords?: string[];
}

interface SearchResponse {
  objects: {
    package: {
      name: string;
      description: string;
      version: string;
      keywords: string[];
    };
  }[];
}

export async function searchPackages(term: string): Promise<PackageSummary[]> {
  const res = await fetch(
    `https://registry.npmjs.org/-/v1/search?text=${term}&size=10`
  );
  const data: SearchResponse = await res.json();

  return data.objects.map(
    ({ package: { name, description, version, keywords } }) => {
      return {
        name,
        description,
        version,
        keywords,
      };
    }
  );
}
ts
// loader function, ex. pages/search/searchLoader.ts
import { searchPackages } from "../../api/queries/searchPackages";
import type { PackageSummary } from "../../api/queries/packageSummary";

export interface SearchLoaderResult {
  searchResults: PackageSummary[];
}

export async function searchLoader({
  request, // request includes the url we are currently at in our router, can be parsed for query params
  params, // named params in the route, ex. /packages/:name would be params.name
}: {
  request: Request;
}): Promise<SearchLoaderResult> {
  const { searchParams } = new URL(request.url);
  const term = searchParams.get("term");

  if (!term) {
    throw new Error("Search term must be provided");
  }

  const results = await searchPackages(term);

  // Good habit to return object, makes it easier to add more data in the future
  return {
    searchResults: results,
  };
}
ts
// router
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Root from "./pages/Root";
import HomePage from "./pages/home/HomePage";
import { homeLoader } from "./pages/home/homeLoader";
import SearchPage from "./pages/search/SearchPage";
import { searchLoader } from "./pages/search/searchLoader";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        index: true,
        element: <HomePage />,
        loader: homeLoader,
      },
      {
        path: "/search",
        element: <SearchPage />,
        loader: searchLoader,
      },
    ],
  },
]);
ts
// in component
import { useLoaderData } from "react-router-dom";
import { SearchLoaderResult } from "./searchLoader";

export default function SearchPage() {
  const { searchResults } = useLoaderData() as SearchLoaderResult;

  // ... use search results
}

Redux Toolkit Queries

RTKQ is a library that allows you to create APIs to define how you want to query and manipulate your data via requests. It also automatically creates some hooks to let you know when data is loading, fetching, and functions to refetch, as well as hooks to automatically refetch data when some piece of data is changed by one of the API endpoints.

ts
// store/apis/photosApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { faker } from "@faker-js/faker";
import Album from "../models/Album";
import Photo from "../models/Photo";

const photosApi = createApi({
  // path in the root state variable
  reducerPath: "photos",
  baseQuery: fetchBaseQuery({
    baseUrl: "http://localhost:3005",
  }),
  tagTypes: ["Photo", "AlbumPhotos"],
  endpoints(builder) {
    return {
      fetchPhotos: builder.query({
        // Create a list of tags that will trigger a refetch if any other endpoint invalidates one of the tags
        providesTags: (result: Photo[], _, album: Album) => {
          const tags = result.map((photo) => {
            return { type: "Photo", id: photo.id };
          });
          // Is is also common to use a tag like { type: "Photo", id: "LIST/ALL/*" } to have an entry to invalidate the entire list
          // However, this will cause a refetch of all Photos lists if there are multiple
          tags.push({ type: "AlbumPhotos", id: album.id });
          return tags;
        },
        query: (album: Album) => {
          return {
            url: "/photos",
            method: "GET",
            // appended onto url
            params: {
              albumId: album.id,
            },
          };
        },
      }),
      addPhoto: builder.mutation({
        // triggers refetching for this album
        invalidatesTags: (_, __, album: Album) => {
          return [{ type: "AlbumPhotos", id: album.id }];
        },
        query: (album: Album) => ({
          url: "/photos",
          method: "POST",
          // request body parameters
          body: {
            albumId: album.id,
            url: faker.image.abstract(150, 150, true),
          },
        }),
      }),
      removePhoto: builder.mutation({
        invalidatesTags: (_, __, photo: Photo) => {
          return [{ type: "Photo", id: photo.id }];
        },
        query: (photo: Photo) => ({
          url: `/photos/${photo.id}`,
          method: "DELETE",
        }),
      }),
    };
  },
});

// Automatically creates functions to access your endpoints
export const {
  useFetchPhotosQuery,
  useAddPhotoMutation,
  useRemovePhotoMutation,
} = photosApi;
export { photosApi };
ts
// store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import { photosApi } from "./apis/photosApi";

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

setupListeners(store.dispatch);

export {
  useFetchPhotosQuery,
  useAddPhotoMutation,
  useRemovePhotoMutation,
} from "./apis/photosApi";
tsx
// component
import { useFetchPhotosQuery, useAddPhotoMutation } from "../store";

export default function Comp({ album }) {
  // Automatically loads data on component creation
  const { data, isLoading, error, isFetching, refetch } =
    useFetchPhotosQuery(album);

  // Hook to call function when needed
  // results contains data, isFetching, error, etc and is updated when function is called and request processes
  const [addPhoto, results] = useAddPhotoMutation();
}

Thunks

Thunks are a vanilla way of using Redux Toolkit to send requests and keep track of what stage those requests are in. These can be listened to in a slice's extraReducers to perform state updates. Each thunk has a pending, fulfilled, and rejected state.

ts
// store/thunks/addUser.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
import { faker } from "@faker-js/faker";

const addUser = createAsyncThunk("users/add", async () => {
  const response = await axios.post("http://localhost:3005/users", {
    name: faker.name.fullName(),
  });

  return response.data;
});

export { addUser };
ts
// store/slices/user.ts
import { createSlice } from "@reduxjs/toolkit";
import { fetchUsers } from "../thunks/fetchUsers";

const initialState: UserState = {
  data: [],
  isLoading: false,
  error: null,
};

const usersSlice = createSlice({
  name: "users",
  initialState,
  reducers: {
    setUsers: (state, action) => {
      state.data = action.payload;
    },
  },
  extraReducers: (builder) => {
    // fetch users
    builder.addCase(fetchUsers.pending, (state, _) => {
      state.isLoading = true;
      state.error = null;
    });
    builder.addCase(fetchUsers.fulfilled, (state, action) => {
      state.isLoading = false;
      state.data = action.payload;
    });
    builder.addCase(fetchUsers.rejected, (state, action) => {
      state.isLoading = false;
      state.error = action.error || "An error occurred.";
    });
  },
});

export const usersReducer = usersSlice.reducer;
ts
// store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import { usersReducer } from "./slices/usersSlice";

export const store = configureStore({
  reducer: {
    users: usersReducer,
  },
});

// Export thunk function from store index
export * from "./thunks/fetchUsers";
export * from "./thunks/addUser";
export * from "./thunks/deleteUser";

Optional Custom Hook

ts
// hooks/use-thunk.ts
import { AsyncThunk } from "@reduxjs/toolkit";
import { useState, useCallback } from "react";
import { useAppDispatch } from "./store";

// Custom hook for handling loading and error states
export default function useThunk(
  thunk: AsyncThunk<any, any | void, {}>
): [(arg?: any) => void, boolean, object | null] {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<object | null>(null);
  const dispatch = useAppDispatch();

  const runThunk = useCallback(
    (arg?: any) => {
      setIsLoading(true);
      dispatch(thunk(arg))
        .unwrap()
        .catch((err: object) => setError(err))
        .finally(() => setIsLoading(false));
    },
    [dispatch, thunk]
  );

  return [runThunk, isLoading, error];
}
ts
// use in component
import useThunk from "../hooks/use-thunk";
import { fetchUsers, addUser } from "../store";

export default function UsersList() {
  const [runFetchUsers, isFetching, fetchError] = useThunk(fetchUsers);
  const [runCreateUser, isCreating, createError] = useThunk(addUser);

  useEffect(() => {
    runFetchUsers();
  }, [runFetchUsers]);

  const handleUserAdd = () => {
    runCreateUser();
  };
}