In modern react one of the most common hooks that are used are useState, where in a react component you set an initial value, and the hook returns access to the value and a way to update the value.
Majority of the time the initial value is just a set string, number or value passed from it props, but they are times where you need to pass a function to work out the initial value based off it props or browser apis.
However using a function to get the initial state can come at a performance risk very easily by using the wrong type of function, which can lead to the initialise function running on every re-render of the component.
The Problem
Let create a very basic react counter component which useState takes an function that consoles logs to browser and gives back the initial value, we then have two button which increase or decrease the value.
function getInitialValue() {
console.log('getInitialValue')
// <Add Custom Logger Here>
return 0
}
export function CounterComponent() {
const [count, setCount] = useState(getInitialValue())
return (
<div>
<button onClick={() => setCount(prev => prev - 1)}>Decrease Count</button>
<div>{count}</div>
<button onClick={() => setCount(prev => prev + 1)}>Increase Count</button>
</div>
)
}
Interacting with the component that causes a re-render such as changing the count will actually cause the console log in the getInitialValue function to appear again, for this demo I have triggered a new event to add count the number of times the value logs.
Our example here is a very basic function to get the initial value, however more complicated functions could build up performance issues in components that often re-render.
But why does this happen ?
This is how vanilla javascript executes when it comes across a function with brackets, so each time the component re-renders and comes across that useState it executes the function immediately and passes that to the useState, it however does not replace the initial state.
So how can we avoid this ?
The fix to this issue is actually is changing how we call these functions in useState, if we don't have any params to pass to the function we can just pass the function with no brackets, if we do we can use a arrow function to solve the issue.
function getInitialValue() {
console.log('getInitialValue')
// <Add Custom Logger Here>
return 0
}
export function CounterComponent() {
const [count, setCount] = useState(getInitialValue)
// or
// const [count, setCount] = useState(() => getInitialValue())
return (
<div>
<button onClick={() => setCount(prev => prev - 1)}>Decrease Count</button>
<div>{count}</div>
<button onClick={() => setCount(prev => prev + 1)}>Increase Count</button>
</div>
)
}
This shouldn't trigger repeated console logs as we are passing a callback function which will only run when if it is called inside useState.
Using ESLint to catch this
ESLint is a popular tool in javascript applications to enforced particular standards to help teams write cleaner code, and more consistent code with the team.
Prior to this in projects I worked in that had ESLint rules, these were using existing plug-ins such as eslint-plugin-react , or custom ones were added by someone else, meaning I had never made my own custom rule which I wanted to try for this case as both a learning opportunity and not finding an existing plugin with same rule.
Making my own ESLint rule
Making my own ESLint rule took a bit of trial and error having not done this before, originally I was hoping to create an internal custom plugin for the app I had and then look to create it own package, however I couldn't find a way.
In the end I created a folder at root of the monorepo so outside of the project called `eslint-plugin-custom-performance-react` and I used `yarn init` inside the folder to give it own package.json with it main being index.js, you may need to find the command for your package manager.
Inside this I created two Javascript files, the first being the file that writes the custom ESLint rule , the meta part of the object is a just information around the problem, the create method is what ESLint uses to find if something is a issue or not.
The CallExpression is looking for functions represents any function call, then inside the if statement we are narrowing down that the original call came from a useState function inside a component which has one argument, it then checks that one argument is also a CallExpression, if so this triggers the ESLint error.
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Performance Impact: Avoid calling functions that execute immidently directly in useState. These will trigger on each rerender but not update useState',
category: 'Best Practices',
recommended: false,
},
fixable: 'code',
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.name === 'useState' &&
node.arguments.length > 0 &&
node.arguments[0].type === 'CallExpression'
) {
context.report({
node: node.arguments[0],
message: 'Performance Impact: Avoid calling functions that execute immidently directly in useState. These will trigger on each rerender but not update useState',
});
}
},
};
},
};
Then I created the index.js file so the package manager could find the ESLint rule, this gets the functions and exports it as a rules object.
const noImmidentlyExecutedFunctionsInUseState = require('./no-immidently-executed-functions-in-useState');
module.exports = {
rules: {
'no-immidently-executed-functions-in-useState': noImmidentlyExecutedFunctionsInUseState,
},
};
Once these files were set up, inside the folder I ran yarn link in the terminal, which created a package that could be used for development to test it out, at this time I not created a proper package for this.
Inside the folder where my react project was I then ran `yarn link "eslint-plugin-custom-performance-react"` (the name should match the name given when running yarn link and the name in the package.json). This created a dependency of the ESLint plugin we wrote to the react project, allowing us to add this new plugin to out ESLint config.
My project uses Nextjs so I had a .eslintrc.json made at the time of project being created, and using ESLint due to a issue with ESLint 9 and NextJS.
So ESLint config file might work differently, ESLint docs.
Once I had installed the ESLint plug in I added the plug-in name 'eslint-plugin-' removed to the ESLint config and then added the rule with plug in name and the rule name and what type of error I wanted to give:
{
"extends": "next/core-web-vitals",
"plugins": ["custom-performance-react"],
"rules": {
"performance-react/no-immidently-executed-functions-in-useState": "error"
}
}
Here is the result, you can see that the first line is showing the lint error but the other two are not.
This was a great way to spot if there were any more places where this performance issue was appearing in a few projects.