Writing Custom React Hooks for D3 Visualizations

In late 2018, the React development team introduced hooks into version 16.8 of the React library. Shifting from class components to functional components, hooks provide a cleaner pattern for reusing stateful logic between components without relying on higher-order components (also known as "wrappers"), which add unnecessary levels to the component hierarchy, and render props, which are messy because of the amount of code they add to the JSX returned by the render method.

In class components, lifecycle methods and component state are rigidly bound to a particular component. Since class components define each lifecycle method only once, a lifecycle method often contains lots of unrelated logic, such as the componentDidMount lifecycle method setting up different event listeners and fetching data from a remote API endpoint. Performing all of these tasks within a single lifecycle method increases the likelihood of introducing bugs and unintended behavior. However, in functional components, all of this logic can be distributed into separate hooks to share this logic with other functional components.

Built-in hooks, such as useState and useEffect, handle component state and side-effects (useEffect consolidates multiple lifecycle methods into a single hook) respectively and make it easy to extract reusable snippets of code into smaller units of functionality. They serve as the building blocks for composing custom hooks. As a React application grows and becomes more complex to accommodate new features, writing components as functional components and delegating shareable logic to custom hooks allow components to not only be lighter, but also, flexible and maintainable.

Below, I'm going to show you how to write custom React hooks.

Custom Hooks Rules#

When writing custom hooks, there are some best practices that improve the clarity of these hooks for other developers.

By convention, the name of a custom hook follows camel casing and starts with the use prefix. Additionally, the name of the hook should describe its purpose. For example, a hook named useCsv implies a hook that fetches (and processes) CSV data and notifies the component when the data is available for the component's rendering.

The signature of a custom hook does not adhere to any strict rules. It can accept any number of arguments of any type and return any number of values of any type.

Custom hooks follow all of the same rules as built-in hooks:

  • They must be called at the top level of a React functional component or custom hook. They cannot be called within loops, conditionals or nested functions.

  • They must be called within a React functional component or a custom hook.

Whenever a hook is called within a component, it has isolated local state that is independent of other hook calls. If you call the twice in a single component or call it in two different components, then each call yields state values unaffected by other calls.

Demo Overview#

In this tutorial, we will create multiple draggable, resizable widgets, which are common in analytics dashboard. Each widget displays a simple data visualization. To keep the example simple, there will only be two widgets: one displays a bar chart that shows the frequency of a categorical variable (flower species) and one displays a scatterplot that plots two quantitative variables (petal length against petal width). Both of these visualizations consume the Iris flower data set.

Try it out in the CodeSandbox demo below:


Identifying Shared Stateful Logic
#

Often, functional components in React applications contain similar pieces of functionality such as subscribing to a window event. In this example, both widgets possess the following characteristics, which all require stateful logic:

  • Resizability - Each widget has the CSS resize property set to both to allow the user to resize the widget. Whenever the user resizes the widget, the contents of the widget are scaled proportionally based on the widget's new dimensions. Therefore, each widget must subscribe to this resize event via the ResizeObserver API to capture the widget's new dimensions.

  • Draggability - Each widget contains a dark-gray bar. When the user clicks and drags on this bar, the widget's position changes with respect to the mouse cursor's position.

  • Fetching CSV Data from a Remote Source - Each widget receives CSV-formatted data from a remote source. Once the data is successfully received, transformations can be applied to each row or the entirety of the data to mold it for the <BarChart /> and <Scatterplot /> components.

  • Generating Scales for X-Axis, Y-Axis and Categorical Variables - Bar charts and scatterplots have X- and Y-axes. Although D3 provides methods for drawing these axes (axisTop, axisRight, axisBottom and axisLeft), these axes will instead be rendered by React, and D3 will only be responsible for calculating the axes' scales based on domain and range.

If we extract these pieces of functionality and refactor them into custom hooks, then we can keep our code DRY and leverage them in future visualization components. With custom hooks, we can cleanly express complex logic.

For the characteristics listed above, let's create five custom hooks to encapsulate them:

  • Resizability - useDimensions

  • Draggability - useDragDrop

  • Fetching CSV Data from a Remote Source - useCsv

  • Generating Scales for X-Axis, Y-Axis and Categorical Variables - useScale and  useColorScale

The useDimensions Hook#

When the user resizes a widget by dragging its bottom-right corner, the dimensions of the widget's visualization scale proportionally based on the new widget's new dimensions. When the height of the widget exceeds its base height, then the visualization's height will increase proportionally while preserving its base aspect ratio. When the height of the widget shrinks below its base height, then the visualization will maintain its base height (and width), and the user would need to scroll within the widget to view the hidden portions of the visualization.

The useDimensions hook requires only one argument: baseDimensions. baseDimensions contains the starting dimensions (height, width and margins) of the visualization and the height of the drag bar. By knowing the starting dimensions of the visualization, the hook can calculate its aspect ratio and preserve the visualization's aspect ratio anytime the widget is resized.

To listen for the resize event, the useDimensions hook creates a new instance of ResizeObserver within useEffect to observe for changes on a target, which in this case is the widget. Inside of the ResizeObserver callback, the widget is referenced as entry.

When resizing the widget, the current width and height of the widget are referenced as entry.contentRect.width and entry.contentRect.height respectively. However, when determining the current dimensions of the visualization, the drag bar height must be subtracted from the widget height to obtain the new visualization height. This height is multiplied by the base aspect ratio to obtain the new visualization width.

This hook returns the new visualization dimensions and a ref to set to the resizable element. This element happens to be a <div /> within the <VizResizer /> component, which represents the widget itself.

(src/hooks/useDimensions.js)

The useDragDrop Hook#

When the user clicks on the dark-gray bar within a widget and drags on it, the widget drags with respect to the mouse cursor's position. When the user releases it, the widget remains in the spot it was released at. If multiple widgets happen to overlap one another, then the most recently dragged widget appears above the others.

The useDragDrop hook requires one argument: ref. ref contains a reference to the widget. Having this reference allows the hook to update the widget's position by setting its position to absolute and changing its left and top CSS properties. To calculate the widget's position (with respect to its top-left corner):

  1. Calculate the horizontal and vertical distance between the mouse cursor and the top-left corner of the drag bar. clientX/clientY and getBoundingClientRect values are relative to the top-left corner of the visible part of the page (the viewport). Store these distance values within the variables shiftX and shiftY.

  2. Since pageX and pageY represent the mouse cursor's coordinates relative to the top-left corner of the entire rendered page, subtracting shiftX and shiftY from these values not only results in the new coordinates of the top-left corner of the widget, but also preserves the mouse cursor's position on the drag bar during dragging.

To listen for dragging events, register event listeners on the events mousedown, mousemove, mouseup and dragstart within useEffect to capture the new position of the mouse cursor and change the position of the widget using the above mentioned calculation. When the widget component is unmounted, the hook will unregister these event listeners to avoid memory leaks.

The useDragDrop hook returns triggerRef, which the component can set to a reference of the element dedicated to triggering the drag event, which is the dark-gray bar.

(src/hooks/useDragDrop.js)

The useCsv Hook#

To create data visualizations, you must provide them with data. d3 provides a csv method from its d3-fetch submodule to fetch CSV data from a remote source and parse it into an array of objects, which each represent a row in the CSV data. While waiting for the data to be fetched, a flag should be set to tell the component that the data is being fetched. Once the data is fetched, the flag should be toggled to tell the component that the data is available and ready to be used for rendering the data visualization.

The useCsv hook requires three arguments: url, formatRows, transformResponse. url represents the URL of the CSV data. formatRows is a function that performs a transformation on each row of the CSV data and returns the transformed row. transformResponse is an optional function that performs a transformation on the entirety of the CSV data.

This hook initializes the values of the state variables data and isLoading to an empty array and true respectively. Both of these values are returned by the hook to be accessed by the component that calls it. When the hook is first called, the data has not yet been fetched, so to indicate that it is being fetched, isLoading is set to true. Within the useEffect hook, the hook fetches the CSV data. The fetchData memoized function calls the csv method and waits for the data to be received. Once received, this data is processed and the data state variable is set to this data. After the data state variable is set or an error is encountered while fetching the CSV data, set the isLoading state variable to false to indicate that the CSV data has either been fetched or failed to be fetched. When the csv method fails to fetch the CSV data, data remains set to an empty array, so the resulting visualization will be empty.

(src/hooks/useCsv.js)

The useScale Hook#

When integrating D3 into React, it is recommended to use D3 to perform calculations and use React to render the visualizations. When creating an axis, plotting data points at specific coordinates within a scatterplot or determining the height of a bar within a bar chart, we need a scale function that maps values from the data ("domain") to pixel values within the SVG canvas ("range"). For the scale function to extrapolate an appropriate pixel value for a value that does not exist within the data, you must pick a D3 scale function that accurately depicts the relationship between both sets of values. For a scatterplot, continuous quantitative data should be based on a linear scale (scaleLinear). For a bar chart, categorical data should be based on an ordinal scale (scaleOrdinal).

The useScale hook requires only one argument: params. params contains the values needed to create a scale. By default, by only specifying the data, accessorKey and range options, the hook constructs a linear continuous scale, which scatterplots commonly use for plotting data points and generating axes. The accessorKey determines which specific field's value should be extracted from each record of the data to serve as the scale's domain. The range specifies the values the domain should be mapped to.

Alternatively, you can customize the scale by specifying a different scale function (scaleFn) or explicitly passing a domain (domain). To apply rounding and padding to the range of a band scale (used for charts with an ordinal/categorical dimension), specify the isRangeRound flag to add spacing between bands (bars in a bar chart).

To avoid recreating the scale whenever an unrelated state/prop value changes in the component calling the useScale hook, memoize the scale with useMemo, and only recreate the scale when the value of the params argument changes.

(src/hooks/useScale.js)

The useColorScale Hook#

To distinguish different categories in a visualization, map each category to a unique color. For example, the Iris data contains observations for multiple flower species. If the observations are plotted onto a scatterplot, then coloring each point helps to differentiate the observations by flower species. Visually, colors make it easy to identify patterns and clusters.

To create a color scale that maps each category to a unique color, use an ordinal scale, which maps a set of discrete values to a set of visual attributes. This scale should accept the flower species as the domain and the colors as the range.

Since we already have a custom hook for creating scales, we can call useScale within the useColorScale custom hook to generate this color scale by passing it a unique set of options.

The useColorScale hook requires only one argument, but its fields are unpacked within the hook's signature: data, key and colors. key represents the field within a record that contains the categorical value. To obtain a list of categories, extract the categorical value from each record, pass this list of values to a Set to remove duplicates and convert the set back to an array with the Array.from method. These categories will be memoized to avoid recreating them whenever an unrelated state/prop value changes in the component calling the useColorScale hook.

This hook not only returns a color scale, but also the list of categories in case you want to render a legend within the visualization.

Next Steps#

Explore the demo to see how these custom hooks are used in the <BarChart /> and <Scatterplot /> components. Try writing some custom hooks for your React applications!

Sources#