Modern Data Fetching in React

Sean McQuaid

Twitter - SeanMcQuaidCode

GitHub - seanmcquaid

How many of you have fetched data like this?

const PostsPage = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [posts, setPosts] = useState<Post[]>([]);
  const [error, setError] = useState<unknown | null>(null);

  useEffect(() => {
    setIsLoading(true);

    fetch('https://jsonplaceholder.typicode.com/posts')
      .then(response => response.json())
      .then(data => {
        setPosts(data as Post[]);
      })
      .catch(err => {
        setError(err);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, []);

  return (
    <div>
      <h1>Hello Connect.tech!</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};
const getPosts = (): ThunkAction<void, RootState, unknown, UnknownAction> => async dispatch => {
  dispatch({ type: 'GET_POSTS_REQUEST' });

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts');
    const data = await response.json();

    dispatch({ type: 'GET_POSTS_SUCCESS', payload: data as Post[] });
  } catch (err) {
    dispatch({ type: 'GET_POSTS_FAILURE', payload: err });
  }
};

const PostsPage = () => {
  const dispatch = useAppDispatch();
  const posts = useAppSelector(state => state.posts);

  useEffect(() => {
    dispatch(getPosts());
  }, [dispatch]);

  return (
    <div>
      <h1>Hello Connect.tech!</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

This was fine....

Server StateĀ  !== Client StateĀ 

What if I want to build it myself?

Things You Have to Care About

  • Caching... (possibly the hardest thing to do in programming)
  • Deduping multiple requests for the same data into a single request
  • Updating "out of date" data in the background
  • Knowing when data is "out of date"
  • Reflecting updates to data as quickly as possible
  • Performance optimizations like pagination and lazy loading data
  • Managing memory and garbage collection of server state
  • Memoizing query results with structural sharing
  • Managing the actual state of the request effectively

Good news!!!

Query vs Mutation

Tools We'll Cover

  • React Query
  • Redux Toolkit Query
  • Remix Loaders + Actions
  • Next.js + React Server Components

React/TanStack Query

import { useQuery } from '@tanstack/react-query';

const PostsList = () => {
  const { data, isError, isLoading } = useQuery({
    queryKey: ['getPosts'],
    queryFn: () =>
      fetch('https://jsonplaceholder.typicode.com/posts').then(
        res => res.json() as Promise<Post[]>,
      ),
  });

  return <ul>{data?.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
};
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();

const { data } = useQuery({
  queryKey: ['getPosts'],
  queryFn: () =>
    fetch('https://jsonplaceholder.typicode.com/posts').then(
      res => res.json() as Promise<Post[]>,
    ),
});

const { mutate } = useMutation({
  mutationKey: ['createPost'],
  mutationFn: (body: { title: string; body: string }) =>
    fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',
      body,
    }).then(res => res.json()),
  onSuccess: () => {
    queryClient.invalidateQueries(['getPosts']);
  },
  onError: () => {
    console.log('Do something on error here!');
  },
});

Redux Toolkit Query

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

const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com/',
  }),
  tagTypes: ['Posts'],
  endpoints: builder => ({
    getPosts: builder.query<Post[], void>({
      query: () => ({ url: 'posts' }),
      providesTags: result =>
        result
          ? result.map(({ id }) => ({ type: 'Posts', id }))
          : [{ type: 'Posts', id: 'LIST' }],
    }),
    createPost: builder.mutation<
      Post,
      {
        title: string;
        body: string;
      }
    >({
      query: body => ({
        url: 'posts',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['Posts'],
    }),
  }),
});

export const { useGetPostsQuery, useCreatePostMutation } = postsApi;

export default postsApi;
import { combineReducers, configureStore } from '@reduxjs/toolkit';

const rootReducer = combineReducers({
  [appSlice.name]: appSlice.reducer,
  [postsApi.reducerPath]: postsApi.reducer,
});

const store = configureStore({
  reducer: rootReducer,
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware().concat(postsApi.middleware),
});

export default store;
import { useGetPostsQuery } from '@/store/postsApi';

const PostsList = () => {
  const { data, isError, isLoading } = useGetPostsQuery();

  return <ul>{data?.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
};
// Use this in a component

import { useCreatePostMutation } from '@/store/postsApi';

const [mutate, { isError, isLoading, isSuccess, data }] = useCreatePostMutation();
import { createSlice } from '@reduxjs/toolkit';
import { authApi, User } from '../../app/services/auth';

interface AuthState {
  user: User | null;
  token: string | null;
}

const initialState: AuthState = {
  user: null,
  token: null,
};

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
  extraReducers: builder => {
    builder.addMatcher(
      authApi.endpoints.login.matchFulfilled,
      (state, { payload }) => {
        state.token = payload.token;
        state.user = payload.user;
      },
    );
  },
});

export default authSlice;

Next.js + RSC

async function getData() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    next: { tags: ['posts'] },
  });

  return res.json() as Promise<Post[]>;
}

const PostsList = async () => {
  const posts = await getData();

  return <ul>{posts?.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
};

export default PostsList;

What about loading and error states?

// loading.tsx in the same directory

const Loading = () => {
  return <LoadingSpinner/>;
}

export default Loading;
// error.tsx in the same directory

'use client' // Error components must be Client Components
 
import { useEffect } from 'react'
 
const Error = ({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) => {

  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error)
  }, [error])
 
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  )
}

export default Error;
// next.config.js

module.exports = {
  experimental: {
    serverActions: true,
  },
};
'use server';

import { revalidateTag } from 'next/cache';

const deletePost = async (formData: FormData) => {
    const id = formData.get('id');
    await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
        method: 'DELETE'
    });

    revalidateTag('posts');
}

export default deletePost;
'use client'

import deletePost from './deletePost';
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
 
const ClientComponent = () => {
  const { pending } = useFormStatus();

  return (
    <form action={deletePost}>
      <input type="text" name="id" />
      <button type="submit" disabled={pending}>Delete Post</button>
    </form>
  )
};

export default ClientComponent;

Let's compare and contrast!

// React Query

import { useQuery } from '@tanstack/react-query';

const PostsList = () => {
  const { data } = useQuery({
    queryKey: ['getPosts'],
    queryFn: () =>
      fetch('https://jsonplaceholder.typicode.com/posts').then(
        res => res.json() as Promise<Post[]>,
      ),
  });

  return <ul>{data?.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
};

export default PostsList;

// RTK Query

import { useGetPostsQuery } from '@/store/postsApi';

const PostsList = () => {
  const { data } = useGetPostsQuery();

  return <ul>{data?.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
};

export default PostsList;
// Next.js + React Server Components

async function getData() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    next: { tags: ['posts'] },
  });

  return res.json() as Promise<Post[]>;
}

const PostsList = async () => {
  const posts = await getData();

  return <ul>{posts?.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
};

export default PostsList;

Parting Thoughts

Modern Data Fetching in React

By Sean McQuaid

Modern Data Fetching in React

  • 88