Introduction

The micro-frontend (MFE) applications throughout the Open edX platform rely on APIs exposed by the backend services. When an MFE gets response data back from an API request, there are a few common approaches to where these data are typically stored and accessed throughout MFEs today, e.g.:

  1. Redux. Transform and place all data returned by the APIs into a global application store.

  2. React Context. Transform and place all data returned by the APIs into component-based context.

  3. React State. Transform and place all data returned by the APIs into component-based state.

  4. Composition.

  5. Blend of all the above.

Historically, using Redux came with a fair amount of repetitive boilerplate that would make working with it a bit cumbersome, though many of the concerns around boilerplate were mitigated through the use of @reduxjs/toolkit.

As vanilla React began to support more Redux-like features natively (e.g., useContext, useReducer), relying on Redux for the patterns of avoiding prop drilling and working with reducers & actions became less of a necessity. However, Context has some caveats around performance and re-rendering that require careful attention (i.e., if your component subscribes to a single property within a Context provider, your component will re-render on any change to the context. There are some workarounds for this, e.g. use-context-selector [source]).

Over time, even newer paradigms and tools have been created to further improve data and state management within React applications, requiring less overall boilerplate. For example, @tanstack/react-query (the focus of this document) was created to be purpose-built for working with asynchronous server state rather than client state, which libraries like Redux are more well-suited for.

For a more in-depth comparison between Redux and @tanstack/react-query, see https://tanstack.com/query/v4/docs/react/comparison.

Client vs. server state

One of the biggest distinctions @tanstack/react-query makes is between client and server state for web applications:

As @tanstack/react-query calls out, server state has a distinct set of concerns when compared to client state:

@tanstack/react-query helps deal with these challenges in a more built-in, intuitive, and lightweight way compared to Redux (where we use a client-side state management library for server state):

Here are the claims from @tanstack/react-query:

Getting started

@tanstack/react-query ships with a provider component and 2 primary React hooks that your MFE application will interact with:

Also, @tanstack/react-query ships with important defaults (“aggressive but sane”) that are worth familiarizing yourself with. Some examples:

QueryClientProvider

The first step when using @tanstack/react-query is to wrap your entire application with the QueryClientProvider component, passing it a queryClient object.

This provider ensures all queries/mutations through the application has access to the query client.

The queryClient used in the QueryClientProvider also houses the common, shared configuration for @tanstack/react-query applicable to all queries throughout the application.

Code example

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

const queryClient = new QueryClient({
  queries: {
    retry: true, // optional: you may disable automatic query retries for all queries or on a per-query basis.
  },
})

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

useQuery

API Reference

This hook is used for retrieving data (e.g., GET requests). It returns all metadata about the query, including its current state:

const {
  data,
  error,
  isLoading,
  isFetching,
  isInitialLoading,
  status,
  refetch,
  // ... see API reference for full list of properties
} = useQuery({ queryKey, queryFn });

// Previously...

const [isLoading, setIsLoading] = useState()
const [error, setError] = useState()
const [data, setData] = useState()

There are (usually) 2 required parameters with useQuery:

All query options that may be customized globally via QueryClientProvider may also be passed on a per-query basis.

Code example

function Example() {
  const { isLoading, isFetching, isInitialLoading, error, data } = useQuery({
    queryKey: ['repoData'],
    queryFn: () =>
      // Example API call with `fetch`
      return fetch('https://api.github.com/repos/tannerlinsley/react-query').then(
        (res) => res.json(),
      ),
      
      // Example API call with `getAuthenticatedHttpClient`
      return getAuthenticatedHttpClient().get(...);
  })

  if (isLoading) { return 'Loading...' }

  if (error) { return 'An error has occurred: ' + error.message }

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>✨ {data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </div>
  )
}

useMutation

API Reference

This hook is used for retrieving data (e.g., POST requests).

TBD

Code example

function App() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
    onSuccess: () => {},
    // ...
  })

  return (
    <div>
      {mutation.isLoading ? (
        'Adding todo...'
      ) : (
        <>
          {mutation.isError ? (
            <div>An error occurred: {mutation.error.message}</div>
          ) : null}

          {mutation.isSuccess ? <div>Todo added!</div> : null}

          <button
            onClick={() => {
              mutation.mutate({ id: new Date(), title: 'Do Laundry' })
            }}
          >
            Create Todo
          </button>
        </>
      )}
    </div>
  )
}

Invalidating the query cache

By default, @tanstack/react-query will cache resolved query data for 5 minutes on the client before it’ll re-fetch the data from the external service. However, in certain cases, you may need to invalidate the query cache yourself. The cache invalidation is done based on the queryKey.

For example, say there is a query that returns a list of todos. The user submits a form to add a new todo. The original query won’t be aware of the newly created todo until the query returning the list of todos is invalidated.

See the documentation for more details.

Code example

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

// Get QueryClient from the context
const queryClient = useQueryClient()

queryClient.invalidateQueries({ queryKey: ['todos'] })

// Both queries below will be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
  queryKey: ['todos', { page: 1 }],
  queryFn: fetchTodoList,
})

// queryClient.setQueryCache(['todos'], )

Best practices

We can learn from the broader JS community in terms of adopting best practices around the use of @tanstack/react-query.

(Note: non-exhaustive)

Create custom React hooks

Always wrap useQuery with a custom React hook (source):

Query Keys

Testing

https://tkdodo.eu/blog/testing-react-query

Other neat features of @tanstack/react-query

(Note: non-exhaustive)

Resources

General

Open edX-specific