Writing A Polling React Hook
React Hooks are the modern approach to managing state and side-effects in React components. When hook logic becomes complex, we can encapsulate the logic into a custom hook. Here we will learn about hooks, why we use them, and how to create a custom hook to retrieve data from a polled endpoint.
Why Hooks?
Early React components were written as class components. State would be stored on the class instance itself, and side-effects were handled in special, overridable methods. Function components became more popular as they were simpler, but had no way to store state. Hooks are React’s solution to allowing state in functional components and are now the preferred way to write React components.
To someone new to React, hooks might seem complicated, but they are in service of some of the complexities of React. When attempting to store state for a component, we encounter challenges:
- Components are just functions.
- This means that we can’t store state in the component itself.
- State is preserved on an instance of the component.
- Multiple instances of the same component can exist at the same time.
- This rules out global variables and state bound to the function itself.
- State must be preserved between renders of the component.
- A component can be rendered/called multiple times and shouldn’t lose its state.
- This rules out local variables defined in the function, as a rerender would reset them.
As a result of these limitations we are left with hooks. Specifically, hooks keep track of state by storing it keyed by the component’s location in the component tree. React has several built-in/core hooks that serve as the foundation for other hooks. We’ll look at a few.
Hooks Basics
Use State
Many components are stateless, but some have some internal state. This internal state is useful to encapsulate component logic. We use the built-in useState
hook to create state variables. React Docs
import { useState } from "react";
function MyComponent() {
// Create a state variable with an initial value
const [value, setValue] = useState(initialValue);
function reset() {
// value = 0; ⛔
// Don't try setting the value directly, it won't work.
// Use the setter function instead.
// Provide a value directly
setValue(0);
}
function increment() {
// Or use an updater function
setValue((p) => p + 1);
}
return (
<>
<div>Value: {value}</div>
<button onClick={increment}>Increment</button>
<button onClick={reset}>Reset</button>
</>
);
}
Use Effect
Sometimes we want to perform an action when something changes in our component. This could be fetching data, logging changes, or calling a callback. We use the useEffect
built-in hook to run side-effects in our components. With useEffect
, we provide a function to run and an array of dependencies. The function will run whenever the dependencies change. React Docs
There are two main ways to use useEffect
:
- Run once on mount.
- Pass an empty dependency array.
- Setup and teardown logic, like event listeners.
- Run whenever a dependency changes.
- Pass an array of dependencies.
- Fetching data, logging changes, calling a callback.
import { useState, useEffect } from "react";
function MyComponent(props: { userId: number }) {
const [username, setUsername] = useState("");
// Empty dependency array means run once on mount
useEffect(() => {
console.log("Component mounted");
// Optional
return () => {
console.log("Component unmounted");
};
}, []);
// Runs whenever the dependency changes
useEffect(() => {
console.log("User ID changed. Fetching new user");
fetchUser(userId).then(async (user) => {
console.log("User fetched");
setUsername(user.username);
});
}, [userId]);
return <div>Username: {username}</div>;
}
Other Hooks
There are several different built-in hooks. Not all are common, but likely you will come across:
useRef
- Reference a value that’s not needed for rendering.useContext
- Read and subscribe to context from your component.
You can use 3rd party hooks from libraries like react-use
. In the next section, we will use the useInterval
hook from react-use
to create a polling hook.
We can also write our own by combining existing hooks.
Combining hooks
In essence, complex hooks are just functions that use other hooks. We can create higher level logic using both other built-in hooks, custom hooks and even 3rd party libraries.
function useCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(count + 1);
}
useEffect(() => {
console.log("Count changed to", count);
}, [count]);
return { count, increment };
}
Writing A Polling Hook
We want to create a hook that will poll a function at a regular interval. The hook should regularly poll some async resource and stop when a condition is met. For example, this could be polling a server for a status until it is complete
.
The Setup
Let’s start with a skeleton. We have some inputs, some outputs, and the hook itself. Lets call our hook useAsyncPolling
.
import {} from "react";
interface HookArgs<T> {
// Our inputs
}
interface HookResult<T> {
// Our outputs
}
function useAsyncPolling<T>({}: HookArgs<T>): HookResult<T> {
// Our hook logic
return {};
}
We should start by thinking about the inputs and outputs of our hook. We want to provide:
- A function to poll.
- This function should return a promise.
- An interval duration.
- A stop condition.
- This function should return
true
when the polling should stop.
- This function should return
- An optional running flag.
- This flag can be used to start and stop the polling.
- Dependencies.
- The hook should reset the result and polling when these change.
And for a return value:
- The result of the polling.
- A flag indicating if we are polling.
import {} from "react";
interface HookArgs<T> {
// added 1-5
pollFn: () => Promise<T>;
intervalMs: number | null;
stopFn: (t: T) => boolean;
running?: boolean;
dependencies: unknown[];
}
interface HookResult<T> {
// added 1-2
result: T | null;
pending: boolean;
}
function useAsyncPolling<T>({
// highlight 1-5
pollFn,
intervalMs,
stopCondition,
running,
dependencies,
}: HookArgs<T>): HookResult<T> {
// Our hook logic
// highlight 1
return { result: null, pending: false };
}
Adding State
We know there are two important pieces of state we need tor return: the result and if we are currently polling. We can use the useState
hook to create these. We can define our pending
state simply as if we have a result or not.
// highlight 1
import { useState } from "react";
interface HookArgs<T> {
// added 1-5
pollFn: () => Promise<T>;
intervalMs: number | null;
stopFn: (t: T) => boolean;
running?: boolean;
dependencies: unknown[];
}
interface HookResult<T> {
// added 1-2
result: T | null;
pending: boolean;
}
function useAsyncPolling<T>({
pollFn,
intervalMs,
stopCondition,
running,
dependencies,
}: HookArgs<T>): HookResult<T> {
// added 1
const [result, setResult] = useState<T | null>(null);
// added 1
const pending = result == null;
// highlight 1
return { result, pending };
}
Calling The Interval
Its important to compose hooks together and avoiding writing code that has already been written. We can use the useInterval
hook from react-use
to call our polling function at a regular interval. This hook will call a function at a regular interval and can be stopped. useInterval
accepts a function to call and an interval duration. Note that the interval duration can be null
to stop the interval.
import { useState } from "react";
// added 1
import { useInterval } from "react-use";
interface HookArgs<T> {
pollFn: () => Promise<T>;
intervalMs: number | null;
stopFn: (t: T) => boolean;
running?: boolean;
dependencies: unknown[];
}
interface HookResult<T> {
result: T | null;
pending: boolean;
}
function useAsyncPolling<T>({
pollFn,
intervalMs,
stopCondition,
running,
dependencies,
}: HookArgs<T>): HookResult<T> {
const [result, setResult] = useState<T | null>(null);
// added 1
const [awaiting, setAwaiting] = useState(false);
const pending = result == null;
// added 1-2
// Null interval duration means don't run
const interval = pending && (running ?? true) ? intervalMs : null;
// added 1-13
useInterval(async () => {
// Make sure the async function is not already being awaited
if (awaiting) return;
setAwaiting(true);
const value = await pollFn();
setAwaiting(false);
const stop = stopCondition(value);
if (stop) {
setResult(value);
}
}, interval);
return { result, pending };
}
Resetting The Result
We should reset the result when the dependencies change. This will allow the hook to start polling again when the dependencies change. We can use the useEffect
hook to reset the result when the dependencies change.
// highlight 1
import { useState, useEffect } from "react";
import { useInterval } from "react-use";
interface HookArgs<T> {
pollFn: () => Promise<T>;
intervalMs: number | null;
stopFn: (t: T) => boolean;
running?: boolean;
dependencies: unknown[];
}
interface HookResult<T> {
result: T | null;
pending: boolean;
}
function useAsyncPolling<T>({
pollFn,
intervalMs,
stopCondition,
running,
dependencies,
}: HookArgs<T>): HookResult<T> {
const [result, setResult] = useState<T | null>(null);
const [awaiting, setAwaiting] = useState(false);
const pending = result == null;
// Null interval duration means don't run
const interval = pending && (running ?? true) ? intervalMs : null;
useInterval(async () => {
// Make sure the async function is not already being awaited
if (awaiting) return;
setAwaiting(true);
const value = await pollFn();
setAwaiting(false);
const stop = stopCondition(value);
if (stop) {
setResult(value);
}
}, interval);
// added 1-4
// Reset result on deps change.
useEffect(() => {
setResult(null);
}, dependencies);
return { result, pending };
}
Handling function changes
One tricky part about hooks is that often functions are passed as dependencies and can change between renders. As we have it now, the function fn
and stopCondition
are bound to the versions we initially pass in to the use interval. This can create unintended issues, such as using difference values in the functions on future render and not seeing that update. To avoid this, we can use a useRef
to store the function without triggering rerenders. We are telling React “this is the function we want to use, don’t update it”. This is a common pattern when dealing with functions in hooks.
import { useEffect, useRef, useState } from "react";
import { useInterval } from "react-use";
interface HookArgs<T> {
pollFn: () => Promise<T>;
intervalMs: number | null;
stopFn: (t: T) => boolean;
running?: boolean;
dependencies: unknown[];
}
interface HookResult<T> {
result: T | null;
pending: boolean;
}
function useAsyncPolling<T>({
pollFn,
intervalMs,
stopCondition,
running,
dependencies,
}: HookArgs<T>) {
const [result, setResult] = useState<T | null>(null);
const [awaiting, setAwaiting] = useState(false);
const pending = result == null;
// added 1-2
const pollFnRef = useRef(pollFn);
pollFnRef.current = pollFn;
// added 1-2
const stopConditionRef = useRef(stopCondition);
stopConditionRef.current = stopCondition;
// Null interval duration means don't run
const interval = pending && (running ?? true) ? intervalMs : null;
useInterval(async () => {
// Make sure the async function is not already being awaited
if (awaiting) return;
setAwaiting(true);
// highlight 1
const value = await pollFnRef.current();
setAwaiting(false);
// highlight 1
const stop = stopConditionRef.current(value);
if (stop) {
setResult(value);
}
}, interval);
// Reset result on deps change.
useEffect(() => {
setResult(null);
}, dependencies);
return { result, pending };
}
Error handling
Error handling is left as an exercise for the reader 🙂