Client-side data fetching is an essential part of the web app at Wealthfront.
Traditionally, we fetched data from the Wealthfront API globally: we’d load all the data necessary for the entire page and store it in global state for components on the page to pull from. Crucially, the bulk of this data fetching logic was performed with Redux, outside any React component. This approach worked great in the era of class components, since it would have been difficult to reuse any data-fetching logic that was performed in a component itself – think of the clunkiness associated with higher-order components (HOCs) and render-props. That being said, this approach had several disadvantages: working with Redux was boilerplate-heavy, our load-time was as fast as our slowest endpoint, and we encountered intermittent issues with stale data. We were looking for a better way to fetch data at Wealthfront, and React hooks gave us that opportunity.
React version 16.8 introduced hooks, and sharing logic between components became a breeze. Custom hooks took the place of HOCs and render-props, and we saw the opportunity to address some of the disadvantages of global data fetching. Instead of managing fetch calls outside React with Redux, we began to fetch from within the React tree itself. With primitives like useState
and useEffect
, we created a simple custom hook that could be used to fetch whatever data a given component required from the Wealthfront API. Fittingly, we called the custom hook useApi
. useApi
greatly simplified our data fetching patterns, but had many disadvantages of its own: duplicate network requests, additional null checking in components, and more loading and error states for the developer to handle.
To address these issues, we decided to take a step back and re-examine our data fetching patterns as part of a larger infrastructure initiative. It had been some time since we last reviewed data fetching solutions in the React community, and we were interested in new developments. We were looking for a hybrid approach between global and local data fetching, and React Query turned out to be a great option.
We’ve since adopted React Query into the Wealthfront code base, iterated on the library with a number of Wealthfront-specific wrappers, created helper utilities to simplify loading and error states, and migrated nearly every call to the Wealthfront API to our new tools. Let’s walk through the process!
Fetching Globally and Fetching Locally
But first, some background on global and local data fetching.
Global data fetching loads all relevant data from the server at the root of the component tree and renders a single loading state while pending. Similarly, if any data fails to load, it triggers a single error state. The data is recorded in a global store, and child components of the root simply pull from this store to access the data they require. Global data fetching presents an interesting set of pros and cons:
Pros:
- The developer only needs to handle a single loading and error state.
- The developer can assume the fetched data is already loaded in deeply-nested children and avoid null checks – assuming the parent component prevents the children from rendering if a network request fails.
Cons:
- The content on the page is effectively locked behind a loading state until the slowest endpoint is loaded.
- A single fetching failure will trigger an error state for the entire page, even sections which don’t use any data from that particular remote source.
- Global data fetching requires a global store to hold the data: at Wealthfront we use Redux, and setting up Redux actions and selectors for each new remote source can be tedious.
- Populating the global store once on-load can lead to stale data if a child component pulls the data after some delay.
- In large apps, it can be difficult to conceptually connect globally fetched data to the child component that uses it. Similarly, it can be difficult to determine if a remote data call can be safely removed when a feature that uses the data is deleted.
To address these disadvantages, we began to use local data fetching. As mentioned earlier, there’s a similar set of tradeoffs:
Pros:
- By fetching data in the component where it’s used, each component only waits for its own data to load before it can render its content.
- A fetching failure only triggers an error state for the component that requires data from that particular remote source.
- A simpler mental model for the developer, since the data fetching is in the same component as the data rendering. Similarly, when removing a feature, it’s easy to remove any associated data fetching.
- Data is fetched when it’s needed, preventing stale data.
Cons:
- Each component needs to handle its own loading and error state.
- Because loading and error states are handled locally, null checks are necessary locally as well.
There’s also another category of problems with local data fetching: request duplication. There’s no simple way to deduplicate requests coming from multiple components loading data from the same remote source simultaneously.
When looking to improve our data fetching patterns, we sought to embrace the pros and address the cons of each data fetching paradigm as much as possible.
In other words, we were looking for a solution which would:
- Support both global and local data fetching
- When fetching locally, deduplicate simultaneous requests
- When fetching locally, minimize null checks and simplify loading and error states
- When fetching globally, avoid boilerplate associated with a global store
- When fetching globally, revalidate data when it’s pulled from the global store by a child component
We wanted the benefits of local data fetching when fetching data globally, and the benefits of global data fetching when fetching data locally. React Query gave us the best of both worlds.
Introducing React Query
Note: The code snippets below that use React Query are based on version 4.0
React Query (also called Tanstack Query) is a modern data fetching library which provides a great set of primitives for managing data from a remote source on the client. The core of React Query is a global store called a query client that’s managed behind-the-scenes.
With React Query, you pass an asynchronous function, in our case a call to the Wealthfront API, and any data that’s returned from the function is automatically stored in the query client. If another component uses React Query to load the same data, it will pull the cached response from the query client and prevent a loading state. However, in the background React Query will revalidate the data (i.e. load the remote data again). If the data is changed by the revalidation, React Query will re-render the component with the latest updates. This caching strategy is known as stale-while-revalidate, and it’s a great approach to prevent unnecessary loading states while ensuring the user sees up-to-date data.
Out of the box, React Query will also deduplicate simultaneous requests to the same data source, retry failed fetching calls, revalidate data on tab-focus and more.
Building on useQuery
for local data fetching
useQuery
is the most basic utility for fetching data with React Query. It has a simple interface, and can be used like the following:
By default, useQuery
expects two required fields passed to its object parameter: queryFn
and queryKey
. queryFn
is the asynchronous function we call to return our data, and queryKey
is an array that’s used to uniquely identify the data returned from the queryFn
. In exchange for these inputs, useQuery
will return isLoading
and isError
flags, the data returned from the queryFn
, and many other values.
For local data fetching, useQuery
provides everything we need! We can render local loading and error states based on the isLoading
and isError
flags, and process the fetched data as we’d like.
However, there’s a bit of additional work we can do to simplify useQuery
invocations. At Wealthfront, we use an in-house SDK to interact with our API instead of calling fetch
directly, something like the following:
To integrate React Query with our API, we created a wrapper around useQuery
called useApiQuery
. useApiQuery
accepts an API endpoint’s name, the parameters it expects, and all of useQuery
’s options:
As a result, useApiQuery
is much simpler to use than useQuery
:
With this implementation, useApiQuery
improves the ergonomics of calling useQuery
, but at the cost of strong type safety: queryData
is correctly typed as the return type of api.getUser
, but apiQueryData
is typed as unknown!
We can add stronger type safety with a few generics:
With this implementation, the data return field from useApiQuery
is strongly typed based on the endpointName
and params
parameters.
Iterating on useApiQuery
for global data fetching
Since React Query stores its data in the query client – effectively a global store – it’s also a great fit for global data fetching. We can simply call useApiQuery
in the root component and handle our single loading and error state there as well. In a child component, we can also call useApiQuery
, but this invocation will pull cached data from the query client. In the background, React Query will revalidate the endpoint, ensuring it’s up-to-date.
One advantage of global data fetching discussed earlier is the ability to prevent null checks in child components which pull data from the global store. However, the semantics for pulling cached data from the query client are identical to those of fetching data in the root: calling useApiQuery
. How can we differentiate these two situations for the developer, so they know when to assume the data is populated, and when to handle a loading and error state?
We decided to address the issue by adding a new option to useApiQuery
: prefetched
. When the developer passes prefetched
as true
, it serves as an indication to others that the data has already been fetched in a parent component, and that the child can access the data without handling a loading or error state.
The prefetched
flag can also indicate to Typescript that the return type from useApiQuery
should be non-nullable. With a few more generics, we can type the data
return field accordingly when prefetched
is passed as true
:
This works great to communicate to other developers that a useApiQuery
invocation doesn’t need to handle its own loading or error states – a parent has already taken care of that. There’s just one more nuance to consider: the cacheTime
option.
A child component that invokes useApiQuery
with the prefetched
option expects that the data it uses is always populated in the query client. This generally works great, since a useApiQuery
hook with prefetched
should be in a component which, by definition, has a parent that fetches the same data. However, with a default cacheTime
of 5 minutes, there’s a danger that the cache will be cleared after a period of inactivity. With an empty cache, a component with prefetched
will have no data to pull. Since the component also explicitly doesn’t handle a loading state, it’ll presumably throw an error when a property on the data is accessed. Thankfully, the solution is simple: set the cacheTime
option to Infinity
, and avoid clearing the cache during periods of inactivity.
After iterating on useQuery
, we wrote similar abstractions for useMutation
, useQueries
, and other common utilities from React Query. Next, we moved on to simplify our patterns to render loading and error states.
Simplifying Loading and Error States
As a reminder, our wish-list for a data fetching library was the following:
- Support both global and local data fetching
- When fetching locally, deduplicate simultaneous requests
- When fetching locally, minimize null checks and simplify loading and error states
- When fetching globally, avoid boilerplate associated with a global store
- When fetching globally, revalidate data when it’s pulled from the global store by a child component
React Query handles all of these either out-of-the-box or with some minor adjustments – with one exception: #3, minimize null checks and simplify loading and error states. This makes sense, since React Query handles fetching, not rendering. We decided to tackle this problem ourselves, using the isLoading
and isError
flags that React Query provides easy access to. After a few iterations, the solution we came up with was AsyncStatus
.
AsyncStatus
automatically renders a loading state when loading, an error state when errored, and calls the children function otherwise. Calling children as a function is an important nuance: if children is a function that’s only called when the data is successfully fetched, the developer can assume the data is non-null inside the children function! This significantly reduces null checks in components:
The actual implementation of AsyncStatus
includes several other features omitted here for brevity: a minimum loading state duration, and opacity/height animations between the loading state and the rendered content.
AsyncStatus
works great, but it requires a bit of boilerplate. We decided to experiment with a more high-level component that combines AsyncStatus
and useApiQuery
: FetchApiQuery
.
FetchApiQuery
accepts the same parameters as useApiQuery
and AsyncStatus
, but behind the scenes it’ll automatically call useApiQuery
and pass along any relevant props to AsyncStatus
. Finally, FetchApiQuery
will call its children prop with the return value of useApiQuery
, giving the developer easy access to the fetched data.
Looking to the future: React Suspense
Although still experimental, React Suspense promises to address many of the same issues as AsyncStatus
. For example, our AsyncStatus
demo from above:
Could be converted to the following with React Suspense:
Suspense
handles the loading state, ErrorBoundary
handles the error state, and by suspending any rendering while fetching, no null checks are necessary!
When React Suspense is properly stable, we’ll investigate making the transition.
Conclusion
Data fetching solutions have come a long way since we first adopted client-side data fetching at Wealthfront.
By integrating React Query into our data fetching patterns, we’ve improved the developer experience significantly. We introduced standardized utilities for fetching globally and locally and added reusable components to handle loading and error states – all with strict type safety in mind. The user experience has improved as well. Users see fewer loading states with React Query’s caching and are always presented with the most up-to-date version of their data thanks to behind-the-scenes data revalidation. It’s a great win for the Web Platform, our developers, and our users alike.
Disclosures
The information contained in this communication is provided for general informational purposes only, and should not be construed as investment or tax advice. Nothing in this communication should be construed as a solicitation or offer, or recommendation, to buy or sell any security. Any links provided to other server sites are offered as a matter of convenience and are not intended to imply that Wealthfront Advisers or its affiliates endorses, sponsors, promotes and/or is affiliated with the owners of or participants in those sites, or endorses any information contained on those sites, unless expressly stated otherwise.
Cash Account is offered by Wealthfront Brokerage LLC (“Wealthfront Brokerage”), a Member of FINRA/SIPC. Neither Wealthfront Brokerage nor any of its affiliates are a bank, and Cash Account is not a checking or savings account. We convey funds to partner banks who accept and maintain deposits, provide the interest rate, and provide FDIC insurance. Investment management and advisory services–which are not FDIC insured–are provided by Wealthfront Advisers LLC (“Wealthfront Advisers”), an SEC-registered investment adviser, and financial planning tools are provided by Wealthfront Software LLC (“Wealthfront”).
All investing involves risk, including the possible loss of money you invest, and past performance does not guarantee future performance. Please see our Full Disclosure for important details.
Wealthfront Advisers, Wealthfront Brokerage and Wealthfront are wholly owned subsidiaries of Wealthfront Corporation. Copyright 2024 Wealthfront Corporation. All rights reserved.