diff --git a/docs/react/fetching.md b/docs/react/fetching.md new file mode 100644 index 0000000..10eb925 --- /dev/null +++ b/docs/react/fetching.md @@ -0,0 +1,252 @@ +# 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. + +## 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 +): [(arg?: any) => void, boolean, object | null] { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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(); + }; +} +``` + +## Redux Toolkit Query + +RTK Query is an additional library that simplifies the above workflow by automatically creating the loading and error values for us. This works by creating APIs which define the endpoints you want to use to hit the server. diff --git a/docs/react/redux.md b/docs/react/redux.md index 833ed25..17669d6 100644 --- a/docs/react/redux.md +++ b/docs/react/redux.md @@ -132,3 +132,53 @@ function SongPlaylist() { export default SongPlaylist; ``` + +## Optimize with Typescript + +```ts +// store/index.ts +export type AppStore = typeof store; +export type RootState = ReturnType; +export type AppDispatch = AppStore["dispatch"]; +``` + +```ts +// custom hook +import type { TypedUseSelectorHook } from "react-redux"; +import { useDispatch, useSelector, useStore } from "react-redux"; +import type { AppDispatch, AppStore, RootState } from "./store"; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); +export const useAppStore = useStore.withTypes(); +``` + +```ts +// typing your slices +import type { RootState } from "../"; + +interface CounterState { + value: number; +} + +// Define the initial state using that type +const initialState: CounterState = { + value: 0, +}; + +export const counterSlice = createSlice({ + name: "counter", + // `createSlice` will infer the state type from the `initialState` argument + initialState, + reducers: { + // Use the PayloadAction type to declare the contents of `action.payload` + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload; + }, + }, +}); + +// Other code such as selectors can use the imported `RootState` type +export const selectCount = (state: RootState) => state.counter.value; +``` diff --git a/docs/react/sidebar.json b/docs/react/sidebar.json index df2bf08..76ebaa8 100644 --- a/docs/react/sidebar.json +++ b/docs/react/sidebar.json @@ -8,7 +8,8 @@ { "text": "State", "link": "/react/state" }, { "text": "Context", "link": "/react/context" }, { "text": "Routing", "link": "/react/routing" }, - { "text": "Redux", "link": "/react/redux" } + { "text": "Redux", "link": "/react/redux" }, + { "text": "Data Fetching", "link": "/react/fetching" } ] } ]