Skip to content

Frontend — Working With Data

ff6347 edited this page Sep 12, 2023 · 7 revisions

On this page, we will look at how data is managed in this application and how to deal with data when creating a new feature.

Currently, there are 3 types or levels of data in this application, which we will address individually:

  1. External, async data: This represents data fetched from an API, an external JSON file that is read asynchronously, etc.
  2. Component/Feature-based data: This is UI data or business logic related to specific functionality and can be localized (in other words that don't require its state to be accessible throughout the whole application). This is most of the data, and as we will see later, what is recommended 90% of the case.
  3. Global UI data: This is data that requires to be accessed anywhere in the whole source code. In most cases, this isn't necessary, nor even recommended. Continue reading to see why. This state is managed via our custom implementation of Unistore, a simpler redux-like state management solution.

External, async data

As explained by Kent C. Dodds in his Article about React State Management, managing external separately from UI state is very helpful, as it generally behaves differently, is gathered asynchronously, and needs to be cached. We agree, and therefore never treat external data the way we treat UI state.

How we manage external data

We use the excellent library React Query to create custom hooks for our specific purposes. This library lets us define an async function that retrieves a cached version of the data we need while silently re-fetching stale data in the background. This allows for both a better user- and developer-experience.

Custom hooks

We mentioned above that we create custom hooks. We do that to be able to access external data from anywhere in the application, without caring about how and when the data is fetched.

Example hook:

type ItemsType = any[] | undefined;

const reactQueryOptions = {
	staleTime: Infinity, // Per example, fetch once and never again, as the query is never stale
	refetchOnWindowFocus: false, // Per example, disable the default of refetching when focussing window
};

const useItems = () => {
	const { data: items, error } = useQuery<ItemsType[], Error>(
		"items", // Unique key used to gather cached data
		asynchronouslyFetchItems, // Async function fetching the data
		reactQueryOptions, // Options describing when and how to refetch and chache the data
	);
	return { error, items };
};

Example usage:

const MyCustomComponent: FC = () => {
	const { items, error } = useItems();
	if (!error && !items) return "Loading items...";
	return <div>Items: {items.length}</div>;
};

Internal, UI data

One would be tempted to put every piece of state in the global state. However, this leads to more complexity and more difficult refactoring time. Instead, the internal UI state should always be managed as deeply in the component tree as required. Most of the time, this is achieved using React's useState or useReducer hook. This is a good way to handle state but regardless of the technique used, the reason to always limit the state to the deepest part of the component tree structure is to reduce complexity and make refactoring easier. When everything is everywhere, a busy developer will gladly (because they don't know if it's still used somewhere) leave dead code here and there or break another feature that relies on the same piece of state.

The (un)avoidable: Global UI state

As you must have thought by now, there are some scenarios where a piece of state needs to be available anywhere in the codebase. In these cases, and if you really can't avoid it, use the Unistore state.

Unistore is a custom redux implementation that is slightly easier to reason about. It also consists of a store and actions but hides most of redux's boilerplate into a more convenient API.

Two custom hooks are provided to work with the Unistore state:

  • useStoreState, to which must be provided the required state key.
  • useActions, which returns an object containing all the store's actions.

As you can imagine, the first one is for state consumption, and the second one for altering the state.

Example:

const SidebarToggle: FC = () => {
	const isSidebarOpened = useStoreState("isSidebarOpened");
	const { toggleSidebar } = useActions();
	return (
		<button onClick={toggleSidebar}>
			{isSidebarOpened ? "Open" : "Close"}
		</button>
	);
};