Using a Macro to Automatically Generate Deps Arrays for React Hooks
Let’s revisit some React basics to gain a better context for what I’m about to demonstrate in this article. The React hooks API provides a way to manage the state of a function component by memoizing variables, which can later be reloaded from a cache for any consecutive render calls based on their initialization order. To deal with side effects, React established a clear pattern of providing a dependencies array as a second argument for hooks, which can be invalidated once an element in the array has changed. Here’s how it looks:
function MyComponent() {
const [state, setState] = useState(1);
useEffect(() => {
console.log('state', state);
}, [state]);
return null;
}
In this example, whenever we call the setState
function with a new value, a new render call will be triggered, which in turn triggers a chain of reactions that eventually prints the new value of the state.
While this mechanism works, it opened the door to potential risks due to developers forgetting to update the dependencies array based on the variables consumed by the hook handler. To mitigate that, the React team created an ESLint plugin that warns you if you forgot to include something in the dependencies array (see eslint-plugin-react-hooks). But what if we want to create a custom hook? For example:
function MyComponent() {
const [state, setState] = useState(1);
useUpdateEffect(() => {
console.log('new state', state);
}, [state]);
return null;
}
function useUpdateEffect(callback, deps) {
const initialized = useRef(false);
useEffect(() => {
if (!initialized.current) {
return () => {
initialized.current = true;
};
}
return callback();
}, deps);
}
The ESLint rule is designed to work mainly with hooks provided out of the box by the React library. So, the following things should happen when linting:
- The linter will warn us about the
callback
and suggest including it in the dependencies array. - We will also get a warning about
deps
because it has to be a tuple with a known length; otherwise, it will mess up the diff algorithm (relevant for TypeScript only). - The
initialized
ref will be ignored because some variables have no functional meaning in including them. - The
useUpdateEffect
dependencies array will go unnoticed.
What we actually expect to happen is for the implementation of useUpdateEffect
to just work, and for the linter to provide usage warnings whenever we call useUpdateEffect
. We can quickly see some of the complications in utilizing the ESLint rule for hooks. It is strict when unnecessary and somewhat limited in its understanding. In other words, it can be a bit inflexible, so we need to guide it a little bit. There are three approaches we can take to ensure lint safety.
The first approach would be to include useUpdateEffect
in the ESLint config as one of the string literals the linter should observe and wrap the callback in the implementation with useRef
.
{
"rules": {
// ...
"react-hooks/exhaustive-deps": ["warn", {
"additionalHooks": "useUpdateEffect"
}]
}
}
function MyComponent() {
const [state, setState] = useState(1);
useUpdateEffect(() => {
console.log('new state', state);
}, [state]);
return null;
}
function useUpdateEffect(callback, deps) {
const initialized = useRef(false);
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
if (!initialized.current) {
return () => {
initialized.current = true;
};
}
return callbackRef.current();
}, deps);
}
The downside of this approach is that it’s not specific enough, and the linter may process unwanted hooks by accident. If you have ever worked on a large repo with many contributors, you know that this change would require a thorough PR review process, as it would affect the entire project.
The second approach would be to create a callback and feed it to the native useEffect
hook instead of creating an entirely custom effect hook:
function MyComponent() {
const [state, setState] = useState(1);
useUpdateEffect(useCallback(() => {
console.log('new state', state);
}, [state]));
return null;
}
function useUpdateEffect(callback) {
const initialized = useRef(false);
useEffect(() => {
if (!initialized.current) {
return () => {
initialized.current = true;
};
}
return callback();
}, [callback]);
}
In my opinion, this is a significantly better approach than the first one, but it changes the original API and adds some overhead, leaving marks on your code, all just to satisfy the linter.
The third and less commonly adopted approach is to use a macro. A macro is an identifier that acts as a placeholder and can be replaced with custom code by the compiler. Here’s how it looks:
import { autorun } from 'react-autorun';
function MyComponent() {
const [state, setState] = useState(1);
useUpdateEffect(() => {
console.log('new state', state);
}, autorun);
return null;
}
function useUpdateEffect(callback, deps) {
const initialized = useRef(false);
useEffect(() => {
if (!initialized.current) {
return () => {
initialized.current = true;
};
}
return callback();
}, deps);
}
As of today, I haven’t tested this approach on a large-scale project, but it has intrigued me because:
- You don’t have to think about dependencies.
- You can choose whether to opt-in or not, and it’s not very committing.
- It can be used with any hook, not just the ones provided by the React library.
Some of the notable drawbacks of using react-autorun
are its lack of declarativeness and its incompatibility with eslint-plugin-react-hooks/exhaustive-deps
. These factors can pose challenges for adoption, especially for projects that heavily rely on React's ESLint plugin. Therefore, if you're considering being an early adopter, the optimal time to do so is at the beginning of a new project.
The example above will be compiled into the following code:
import { autorun } from 'react-autorun';
function MyComponent() {
const [state, setState] = useState(1);
useUpdateEffect(() => {
console.log('new state', state);
}, autorun(() => [state]));
return null;
}
function useUpdateEffect(callback, deps) {
const initialized = useRef(false);
useEffect(() => {
if (!initialized.current) {
return () => {
initialized.current = true;
};
}
return callback();
}, deps);
}
To get started with react-autorun
, you can install it with NPM and use it in conjunction with Babel or SWC, as specified in the README file.
npm install react-autorun
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
swcPlugins: [
['react-autorun/plugin/swc']
]
}
}
module.exports = nextConfig
What’s nice about react-autorun
is that it comes with a runtime library that gives you fine-grained control over the generated dependencies arrays and whether some variables should be included or not. For example, if we wanted to implement a useLatest
hook, we could exclude its return value from ever being included in the generated dependencies array by calling autorun.ignore
:
import { autorun } from 'react-autorun';
function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return autorun.ignore(ref);
}
It’s important to be cautious when ignoring values as it can have unintended consequences, but when used properly, it can be a powerful tool.