Reading Code - React-Query

May 17, 2022 6 minute read

When I’m exploring an unfamiliar codebase I start with one of its public APIs. In this case, it was useQuery, arguably the most widely used React-Query function. As I followed its execution path I learned a lot.

React-Query has a very simple API that hides a lot of complexity underneath. But the code is written so well that it makes it digestible even for someone who is reading it for the first time.

The authors have leveraged common programming and structural patterns. So the core of the functionality can be understood even by engineers who have no React experience. Here are my main takeaways.

Project Structure

I think the maintainers of React-Query have managed to translate a very important architectural pattern to the front-end world. The library is structured in a way that completely separates the domain logic (in this case data fetching) from anything React specific.

├── src
|   ├── react
|   ├── core
|   ├── ...
|   ├── index.ts

If we peek into the react folder we’ll see all the hooks the library provides together with other React-specific functionality.

├── react
|   ├── useMutation
|   ├── useQuery
|   ├── useQueries
|   ├── useQueryClient
|   ├── ...

The core folder is more interesting to me. It holds the heart of the library, the underlying logic. Its contents are mostly framework agnostic. There are still some edge cases being handled together with references to React but they don’t dictate the implementation.

├── core
|   ├── query
|   ├── queryCache
|   ├── queryClient
|   ├── queryObserver
|   ├── ...

The core folder has only classes and functions inside of it. This is an excellent example of how business logic can be decoupled from the React code in an application. In fact, React-Query’s core logic is written in an object-oriented way, leveraging common practices like inheritance and dependency injection. The react hooks act as bindings.

Hooks as a Bridge

Hooks are the most powerful abstraction mechanism we have in React right now. I’ve made the habit to extract a custom hook for every component that requires more than 5 lines of setup. But they are also the best way to connect your business logic to React in a natural way.

This is what the useBaseQuery hook does in this library.

// Details stripped for brevity...
export function useBaseQuery(
  options: UseBaseQueryOptions,
  Observer: typeof QueryObserver
) {
  const queryClient = useQueryClient()
  const defaultedOptions =
    queryClient.defaultQueryObserverOptions(options)

  // ...

  const [observer] = React.useState(
    () => new Observer(queryClient, defaultedOptions)
  )

  // ...

  let result = observer.getOptimisticResult(defaultedOptions)

  // ...

  return result
}

This is a private hook that gets called by useQuery. It retrieves the query result and sets up the subscription that watches for updates. It acts as the “bridge” between the core and React.

As an engineer who calls the useQuery hook, you receive only the result - an object with simple values and data. You are left unaware of the underlying observer and the workings of the cache.

I’ve written about the idea of having deep modules with shallow interfaces. I think the useQuery hook is a great example of that practice. It has a simple signature and it hides an incredible amount of complexity underneath.

Overriding

Generally, I’m not a fan of using overriding as a practice. I prefer to have multiple functions with different signatures than a single overridden one. I understand why they’ve decided to leverage it for React-Query, though.

// Detailes stripped for brevity...
export function useQuery(options: UseQueryOptions): UseQueryResult
export function useQuery(
  queryKey: TQueryKey,
  options?: Omit
): UseQueryResult
export function useQuery(
  queryKey: TQueryKey,
  queryFn: QueryFunction,
  options?: Omit
): UseQueryResult
export function useQuery(
  arg1: TQueryKey | UseQueryOptions,
  arg2?: QueryFunction | UseQueryOptions,
  arg3?: UseQueryOptions
): UseQueryResult {
  const parsedOptions = parseQueryArgs(arg1, arg2, arg3)
  return useBaseQuery(parsedOptions, QueryObserver)
}

Having a more configurable useQuery hook means that engineers can use the same function regardless of their setup.

Otherwise, they would’ve had to export multiple hooks with slightly different APIs. In this case the end product has affected the technical decisions. Simplicity for the user has taken precedence over simplicity for the maintainer.

It’s interesting that they’ve decided to give the parameters generic names - arg1, arg2, arg3.

Normally, this can be considered a very bad naming practice. But in this case, they immediately pass them to the parseQueryArgs function so this encapsulates the confusion the names may cause only inside the parsing functionality.

Options as Context

As you browse through the codebase you will notice that many of the functions accept a value named options. By the third or fourth time you come across it, you already know what its contents are. It holds the query’s configuration like the query function, the query key, and cache time.

function getNextPageParam(options: QueryOptions, pages: unknown[]) {
  // ...
}

function getDefaultState(options: QueryOptions) {
  // ...
}

function build(
  client: QueryClient,
  options: QueryOptions,
  state?: QueryState
) {
  // ...
}

This seems to be inspired by the context object pattern. Even though most functions only use one or two properties from options, they still accept the whole object. This helps unify the configuration and makes it recognizable.

Also, it’s much easier to pass a single value than multiple ones. The more arguments a function has to accept the more complex its signature. Using the whole object alleviates the problem.

Patterns

I rarely see any of the common design patterns used in the context of React. But this library has an Observer implementation in its core and I found that fascinating. If you’ve ever wondered what React-Query uses to update all components querying the same data - it’s exactly this.

export class QueryObserver extends Subscribable {
  options: QueryObserverOptions
  private client: QueryClient
  private currentQuery!: Query
  private currentQueryInitialState!: QueryState
  private currentResult!: QueryObserverResult
  private currentResultState?: QueryState
  private currentResultOptions?: QueryObserverOptions

  // ...

  constructor(client: QueryClient, options: QueryObserverOptions) {
    super()
    // ...
    this.setOptions(options)
  }
}

When you think about it, the observer design pattern is a great fit to one of the underlying problems of React-Query. There are multiple places using the same piece of data from the cache. So with an observer, we can notify each one of them when this data changes. It also navigates what part of the cache is used when the query key changes.

The useBaseQuery hook instantiates an observer underneath each time you call useQuery as we saw above.

The result you get from useQuery will bring back memories from the times of Redux when we had to manage all these states ourselves. I had perfected the art of writing repetitive data fetching logic back in the day.

But if you look at the actual implementation of the query class, you will notice a very similar pattern.

export class Query {
  // ...

  private dispatch(action: Action<TData, TError>): void {
    this.state = this.reducer(this.state, action)

    notifyManager.batch(() => {
      this.observers.forEach((observer) => {
        observer.onQueryUpdate(action)
      })

      this.cache.notify({ query: this, type: 'queryUpdated', action })
    })
  }

  // ...

  protected reducer(state: QueryState, action: Action): QueryState {
    switch (action.type) {
      case 'failed':
      // ...
      case 'pause':
      // ...
      case 'continue':
      // ...
      case 'fetch':
      // ...
      case 'success':
      // ...
      case 'error':
      // ...
      case 'invalidate':
      // ...
      case 'setState':
      // ...
      default:
        return state
    }
  }
}

It uses a dispatch function with specific actions. This action gets run through a reducer function and the result is passed to the observers. This is a great example of how functional and object-oriented patterns can blend together.

Tao of Node

Learn how to build better Node.js applications. A collection of best practices about architecture, tooling, performance and testing.