Centralizing state and data handling with React Hooks: on the road to reusable components
Tags: react, typescript
Published: 2022-03-10 14:31:33

Application development is often reactive. We see the need, we deliver a solution as fast as possible. During this fast software cycle, we gather requirements and implement them as soon as they appear. I’m not talking about quick and dirty. I’m referring to using the best [RAD](https://en.wikipedia.org/wiki/Rapid_application_development) practices - rapid application development. 
​
The RAD cycle is as follows: you implement great core features ([MVP-style](https://en.wikipedia.org/wiki/Minimum_viable_product)), relying on years of experience to create maintainable code. But over time, several things occur: requirements change, more code gets written, and the codebase starts to rebel against your intuitively brilliant but perhaps not fully robust architecture. So you start refactoring. Also, you discover that technology changes, offering new ways to make your code simpler, cleaner, and more powerful. 
​
**Enter game changer [React Hooks](https://reactjs.org/docs/hooks-intro.html)**. And, a fast growing business that requires you to rewrite your application with loads of new features. 
​
*Rewrite* – from scratch. Life offers a second opportunity. 
​
## How React Hooks saved our administration application
​
Application development can also be pro(Re)active. Our administration application is data-intensive. Previously, many separate (and competing) components had managed their data independently - connecting, formatting, displaying, updating, etc.. 
​
### The requirements of an Admin application
​
An Admin application is a good candidate for centralizing data handling. Administrators need to see the data as is, so the onscreen views usually match the structure of the underlying data. So, while our client-facing dashboard presents functional views for business users, an administrator needs to see user or client subscription information in a consistent and straightforward manner. 
​
What we needed was a more scalable solution. Since we pull data from multiple sources – all accessible via one API with many endpoints – we wanted to centralize the common aspects of data handling. This not only gave us immediate benefits (better testing, caching, syncing, standard typing), it facilitated and simplified future data integrations.  
​
### A customized hook
​
We implemented a custom React hook called <code>useData</code>, which manages and therefore centralizes all data-retrieval API calls, data exchanges, type checking, caching, and other such data-based functionality. The caching alone enhanced user-facing speed enormously. Equally important, the speed and centralization enabled our front-end developers to reuse their components and UI elements in different parts of the interface. Such reusability created a feature-rich, user-friendly UI/UX without front-end developers needing to maintain unique state information within each component. Lastly, under the hood, data reusability enabled a coherence in the models that drove the front-end functionality. We will discuss front-end benefits of React hooks in future articles; this article is about how we served the front-end with a reliable and scalable layer of data handling.   
​
### How our <code>useData</code>hook centralized the process 
​
We use different data sources, some more complex than others but all following the same JsonAPI specification. Additionally, they all have the same needs – a means to:
​
- Retrieve data
- Deserialize and format it
- Validate its format
- Perform error handling (data quality, network)
- Synchronize with app refreshes and other data/workflows
- Cache the data and keep it up to date
​
Enough talking, here is our <code>useData</code> hook code:
​
```ts
import { useCallback } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { ZodObject, infer as Infer } from 'zod';
import { useApi } from 'hooks';
import { metaBuilder, MetaInstance } from 'models';
​
interface Options {
  forceCallApi?: boolean;
  preventGetData?: boolean;
}
​
interface ApiData<T> {
  data?: T;
  meta?: MetaInstance;
}
​
export interface DataResult<Output> {
  data?: Output;
  meta: any;
  loading: boolean;
  errors: Error[];
  refresh: () => Promise<void>;
}
​
export const useData = <Model extends ZodObject<any>, ModelType = Infer<Model>, Output extends ModelType = ModelType>(
  builder: (data: ModelType) => Output,
  url: string,
  { forceCallApi = false, preventGetData = false }: Options = {}
): DataResult<Output> => {
  const queryClient = useQueryClient();
​
  const { getData } = useApi(url);
​
  const getDataFromApi = useCallback(async (): Promise<ApiData<Output>> => {
    // here we get the data (and meta) using getData, and handle errors and various states
    return { data: builder(apiData), meta: metaBuilder(apiMeta) }
  }, [getData, builder, queryClient, url, forceCallApi]);
​
  const { data: getDataResult, isLoading, error } = useQuery<ApiData<Output>, Error>(
    [url, forceCallApi],
    getDataFromApi,
    { enabled: !preventGetData, cacheTime: forceCallApi ? 0 : Infinity }
  );
​
  const refresh = useCallback(async () => {
    await queryClient.refetchQueries([url, forceCallApi], {
      exact: true,
    });
  }, [queryClient, url, forceCallApi]);
​
  return {
    data: getDataResult?.data,
    meta: getDataResult?.meta,
    loading: isLoading,
    errors: ([error]).filter((error) => error !== null) as Error[],
    refresh,
  };
};
```
​
As you can see, this hook takes three parameters that, when combined, give us all the following functionalities: 
​
- A “builder” function that transforms and enhances the data for use by our components
- The URL of the API endpoint that retrieves the data
- Optional parameters. For example, to ignore cache or wait for some other data to be ready before calling the API
​
The result is that our components no longer need to manage all that. We’ve abstracted and encapsulated the complexity.
​
The <code>useData</code> hook returns some values we can use in our components:
​
- Some states: loading and errors (if any)
- The data (if any)
- Meta information (if present – pagination information, for example)
- A refresh function (to refresh the data by calling the API again)
​
## Building the data 
​
Let's take a deeper look at what this code does and how we use it.
​
## Schema validation with Zod
​
Getting the data is one thing. Ensuring that the data is correctly structured, or typed, is another. Complex data types require validation tools like [yup](https://github.com/jquense/yup) or [zod](https://github.com/colinhacks/zod) that enforce efficient and clean methods, and offer tools and error handling runtime errors based on faulty types. Our front end relies on strongly-typed data sets, so the validation stage is crucial for us.
​
We use [zod](https://github.com/colinhacks/zod). Zod is used to build a model of the data. For example, here is what the model for our Application could look like:
​
```ts
import { object, string, number } from 'zod';
​
const Application = object({
  applicationId: string(),
  name: string(),
  ownerEmail: string(),
  planVersion: number(),
  planName: string(),
});
```
​
​
Then, to construct our builder function, we use in-house-built generic helpers on top of the zod model.This helper takes two parameters: 
​
- The model of our data (Application in our example above)
- A transformer function that is used to enrich that model. 
​
In our case, that transformer would look like this:
​
```ts
import { infer as Infer } from 'zod';
​
const transformer = (application: Infer<typeof Application>) => ({
  ...application,
  get plan() {
    return `${application.planName} v${application.planVersion}`;
  },
});
```
​
Another example of enrichment is if a model has a date: we usually want it to expose a javascript date rather than a string date.
​
We have 2 versions of that helper function (one for objects and one for arrays). Below is the first one:
​
```ts
import type { ZodType, TypeOf, infer as Infer } from 'zod';
import { SentryClient } from 'utils/sentry';
​
export const buildObjectModel = <
  Model extends ZodType<any>,
  ModelType = Infer<Model>,
  Output extends ModelType = ModelType
>(
  model: Model,
  transformer: (data: TypeOf<Model>) => Output
): ((data: ModelType) => Output) => {
  return (data: ModelType) => {
    const validation = model.safeParse(data);
    if (!validation.success) {
      SentryClient.sendError(validation.error, { extra: { data } });
      console.error('zod error:', validation.error, 'data object is:', data);
      return transformer(data);
    }
    return transformer(validation.data);
  };
};
```
​
The typed output by zod is very clean and looks like a typescript type that we would have written ourselves, with the addition that zod parses the JSON using our model. For safety, we use the <code>safeParse</code> method from zod, which allows us to send back the JSON “as is” in case of an error during the parsing step. We would also receive an error on our error tracking tool, [Sentry](https://sentry.io/welcome/).
​
With our example, our builder function would look like:
​
```ts
export const applicationBuilder = buildObjectModel(Application, transformer);
// and for the record, here is how to get the type output by this builder:
export type ApplicationModel = ReturnType<typeof applicationBuilder>;
// which looks like this in your code editor:
// type ApplicationModel = {
//   plan: string;
//   applicationId: string;
//   name: string;
//   ownerEmail: string;
//   planVersion: number;
//   planName: string;
// }
```
​
### Calling the API
​
Internally, we use another custom hook <code>useApi</code> (less than 200 lines of code) to handle the GET/POST/PATCH/DELETE. In this hook, we use [axios](https://github.com/axios/axios) to call the backend API and perform all typical [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) functionality. For example, on the read side, Axios deserializes the data we receive before it is converted from the JSON API spec to a more classic JSON, and switching from snake_case to camelCase. It also handles any meta information we receive. 
​
Also, from a process point of view, it manages request canceling and errors when calling the API.
​
### Caching the data
​
At this point, we can summarize: the <code>useApi</code> hook gets the data, which is then passed through the builder to be validated and enriched; and the resulting data is cached using [react-query](https://github.com/tannerlinsley/react-query).
​
We implemented react-query for caching the data on the front end, using the API endpoint URL as the cache key. React-query uses the <code>useApi</code> hook mentioned above to fetch, synchronize, update, and cache remote data, allowing us to leverage all these functionalities with a very small codebase. 
All we have to do on top of that is to implement react-query’s provider. To do so, we’ve constructed a small react component: 
​
```ts
import { FC } from 'react';
import { QueryClient, QueryClientProvider, QueryClientProviderProps } from 'react-query';
​
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      refetchInterval: false,
      refetchIntervalInBackground: false,
      refetchOnMount: false,
      refetchOnReconnect: false,
      retry: false,
    },
  },
});
​
type IProps = Omit<QueryClientProviderProps, 'client'> & {
  client?: QueryClient;
};
​
export const GlobalContextProvider: FC<IProps> = ({
  children,
  client = queryClient,
  ...props
}) => (
  <QueryClientProvider {...props} client={client}>
    {children}
  </QueryClientProvider>
);
```
​
Most importantly, it manages our caching. We have many components that need the same data, so we wanted to avoid unnecessary network traffic to retrieve the same information. Performance is always key. So is limiting potential errors performing unnecessary network calls. Now, with caching, if one component asks for data, our cache will store that data and give it to other components that ask for the same information. In the background, React-query of course ensures that the data in the cache is kept up to date. 
​
To sum up, here is an example of a component built using this <code>useData</code> hook and our Application model as defined above:
​
```ts
import { FC } from 'react';
​
interface ApplicationProps {
  applicationId: string;
}
​
export const ApplicationCard: FC<ApplicationProps> = ({ applicationId }) => {
  const { loading, data: application, errors } = useData(applicationBuilder, `/applications/${applicationId}`);
​
  return loading ? (
    <div>loading...</div>
  ) : errors.length > 0 ? (
    <div>{errors.map(error => (<div>{error}</div>))}</div>
  ) : (
    <div>
      <div>{application.applicationId}</div>
      <div>{application.ownerEmail}</div>
      <div>{application.name}</div>
      <div>{application.plan}</div>
    </div>
  );
};
```
​
As you can see, our <code>useData</code> hook lets us standardize  the loading & errors states, thus encouraging us to write **reusable components** that handle those states. For example, we have reusable <code>StateCard</code> and <code>StateContainer</code> components. With the data now easily available, we can go about integrating those **reusable components**  and focus exclusively on building a great front end experience – cleanly, fully-featured, and scalable.