React: how to define a constant variable
7 min read • Posted on June 30, 2026
#reactIntroduction
Section titled “Introduction”When working with components, a use-case comes up often: computing an operation on component initial mount, and having it be fully stable across the entire lifetime of the component.
Why would you want to do something like this?
- For performance reasons, you may want to compute something very expensive and ensure it never changes,
- Generating an id that has to respect a specific shape (and thus when you can’t use
useId()):- For instance, a random UUID,
- Or a constant object
{}that you use as a stable pointer for objects likeWeakMap
- Generating a new instance of a class, like
new MutationObserver(), or defining a web workernew Worker(), etc. - All of the above, but when you want those variables to be tied to this component’s instance (and can’t move it to the module and just be a constant).
If you were there before February 2019 and the introduction of hooks, there was a simple way to do it with class components:
class MyComponent extends React.Component { constructor(props) { super(props);
this.variable = init(); // <- only compute it when the instance gets created }
// ...}❌ Bad: useMemo()
Section titled “❌ Bad: useMemo()”The easiest way of doing this with React is:
const variable = React.useMemo(() => init(), []);Guess what? It works, and it’s easy. So the end 😄.
…
…
Issue 1: development
Section titled “Issue 1: development”Of course there is a pitfall 🥲. When reading useMemo()’s docs, you can see: “My calculation runs twice on every re-render” in the troubleshooting section. How is it possible?
To go over it quickly, this is because in dev, React will run effects & memos twice to ensure that your components are pure.
“Okay okay, so the pitfall is only in dev, big deal. It won’t affect production builds so it’s fine.”
Weeeeell, it means that we could have small bugs / performance issues in dev, but not in production. I agree that those should be fine but it is still worth mentioning.
Issue 2: throwing away cache
Section titled “Issue 2: throwing away cache”One more thing: when reading the docs for useMemo(), in the “Caveats”, we can read:
React will not throw away the cached value unless there is a specific reason to do that. (…) Both in development and in production, React will throw away the cache if your component suspends during the initial mount. In the future, React may add more features that take advantage of throwing away the cache (…)
Dominik / TkDodo also talked about it a few years ago in his post “useState for one-time initializations”.
What does it mean? React can, and does, decide to call useMemo() again even if no dependencies changed if it feels like it’s necessary.
This means that useMemo() cannot be trusted (by design) for elements that can only be called once per component instance.
❌ Bad: useRef()
Section titled “❌ Bad: useRef()”In the class component, the expensive computation was stored in a class attribute this.variable. With hooks, most of the time, for such variables (that we want to modify outside of React lifecycle mechanism), we use useRef() instead.
Let’s not make it complicated. useRef() can be initialized to a specific value, so let’s use it:
const variableRef = React.useRef(init());This technically works: variableRef.current will always hold the value returned by init() on the first render.
But this has 2 drawbacks:
init(), even if not used, is called at every render. This won’t (most likely) cause you bugs, but can be a huge performance issue,- this is a personal preference: nothing prevents you from mutating
variableRef.currentmanually (without realizing, like if you used the wrong ref).
👍 Okay: useRef() – lazy
Section titled “👍 Okay: useRef() – lazy”To only call init() during initialization, we can follow a lazy initialization pattern:
const variableRef = React.useRef();if (variableRef.current === undefined) { variableRef.current = init();}This code snippet may look weird to you if you’re familiar with React rules: the .current value of the ref is getting written in render, which is banned by the ESLint rules / compiler.
Well actually, this is the only place where it’s not just safe but the recommended way by the React team to do so. They even mention it explicitly in the docs:
Do not write or read
ref.currentduring rendering, except for initialization
This code still has the issue of being able to mutate variableRef.current, but otherwise it’s pretty solid.
Why undefined and not another value?
Why am I using undefined in this code snippet? Let’s give it a try (if you want a live example, see it on the React Compiler Playground):
const EMPTY_POINTER = {};function useConstantMyHooks(init) { const undefinedRef = React.useRef(undefined); if (undefinedRef.current === undefined) { undefinedRef.current = init(); }
const nullRef = React.useRef(null); if (nullRef.current === null) { nullRef.current = init(); }
const numberRef = React.useRef(-1); if (numberRef.current === -1) { numberRef.current = init(); }
const pointerRef = React.useRef(EMPTY_POINTER); if (pointerRef.current === EMPTY_POINTER) { pointerRef.current = init(); }}When running this, you’ll see that React Compiler reports a few errors:
Found 4 errors:
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
12 | 13 | const numberRef = React.useRef(-1);> 14 | if (numberRef.current === -1) { | ^^^^^^^^^^^^^^^^^ Cannot access ref value during render 15 | numberRef.current = init(); 16 | } 17 |
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
13 | const numberRef = React.useRef(-1); 14 | if (numberRef.current === -1) {> 15 | numberRef.current = init(); | ^^^^^^^^^^^^^^^^^ Cannot update ref during render 16 | } 17 | 18 | const pointerRef = React.useRef(EMPTY_POINTER);
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
17 | 18 | const pointerRef = React.useRef(EMPTY_POINTER);> 19 | if (pointerRef.current === EMPTY_POINTER) { | ^^^^^^^^^^^^^^^^^^ Cannot access ref value during render 20 | pointerRef.current = init(); 21 | } 22 | }
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
18 | const pointerRef = React.useRef(EMPTY_POINTER); 19 | if (pointerRef.current === EMPTY_POINTER) {> 20 | pointerRef.current = init(); | ^^^^^^^^^^^^^^^^^^ Cannot update ref during render 21 | } 22 | } 23 |This makes sense: the compiler is only doing a static analysis of the code. So React needs to be able to distinguish this pattern from any regular ref.current === value check.
Due to these constraints, the React team decided to only consider valid checks against undefined and null, both via === undefined, === null, and with == null (which covers both undefined and null at once).
❌ Bad: useState()
Section titled “❌ Bad: useState()”Let’s look at a fully different approach: useState(). It can be initialized with a value, just like useRef():
const [variable] = React.useState(init());This is better than useRef() as you can’t mutate the variable as the setter is not destructured. But the two code snippets share the same issue of init() being called in every render.
✅ Good: useState() – lazy
Section titled “✅ Good: useState() – lazy”To only call the initialization once, this time, useState() comes with a solution available for you out of the box: instead of passing a value to useState(initialValue), you can pass a function: useState(() => initialValue). And this lazy initializer will only be called during the 1st call of the component.
const [variable] = React.useState(() => init());This code is good. Period. No caveat. Do use it.
This is not my personal preference because I don’t find it very explicit for new developers. From afar, it can look very weird to have to define a state for a constant variable that shouldn’t change.
I’ve seen a few codebases adding comments for this kind of code to make it explicit:
// Only calling `init()` once during the initialization.// We can’t use `useMemo()` as React may dispose of the cache even if no dependency has changed.const [variable] = React.useState(() => init());🌟 Best: My personal recommendation
Section titled “🌟 Best: My personal recommendation”Just wrap it in a custom hook.
It doesn’t matter that much which method you’re using: ref / state. The last issue with ref was that people could override the ref.current value. And the biggest issue with the state was the potential confusion of why it’s a state.
Both are solved with a custom hook (with optional comments centralized within it, in only 1 place in the codebase).
// Option A: with useRefconst useConstant = (init) => { const constantRef = React.useRef(); if (constantRef.current === undefined) { constantRef.current = init(); } return constantRef.current;};// Option B: with useStateconst useConstant = (init) => { const [constant] = React.useState(() => init()); return constant;};Nitpick 1: useRef() & undefined
Section titled “Nitpick 1: useRef() & undefined”useRef()’s code still has 1 issue: init() cannot return undefined, otherwise init() will be called at every render. As long as you’re not in this case, using useRef() is fine. If you want tooling to help you, you can use TypeScript with such types:
function useConstant<T>(init: T extends undefined ? never : () => T): T { const ref = React.useRef<T>(undefined); if (ref.current === undefined) { ref.current = init(); } return ref.current;}See in TypeScript Play how it handles errors differently than a plain T generic:
const useConstantBad = <T>(init: () => T): T => { const constantRef = React.useRef<T>(undefined); if (constantRef.current === undefined) { constantRef.current = init(); } return constantRef.current;};
function useConstantGood<T>(init: T extends undefined ? never : () => T): T { const ref = React.useRef<T>(undefined); if (ref.current === undefined) { ref.current = init(); } return ref.current;}
const potentiallyUndefined = 1 as 1 | undefined;
// ❌ – No error, but should throw someuseConstantBad(() => undefined);useConstantBad(() => potentiallyUndefined);
// ✅ – Does throw errors, even for variables that can be something or undefineduseConstantGood(() => undefined);useConstantGood(() => potentiallyUndefined);Nitpick 2: useState() allocation size
Section titled “Nitpick 2: useState() allocation size”I was curious to check how React works under-the-hood when it comes to useRef() and useState() to see if this could help us make a decision between useRef() and useState().
Side note: when reading React’s codebase, you can discard everything within if (__DEV__) { ... } as those are only guards for development builds. And here I’ll only compare production code.
When checking the source code for useRef(), you can see that it barely does anything, it just defines a memory slot and retrieves it:
function mountRef<T>(initialValue: T): { current: T } { const hook = mountWorkInProgressHook(); const ref = { current: initialValue }; hook.memoizedState = ref; return ref;}
function updateRef<T>(initialValue: T): { current: T } { const hook = updateWorkInProgressHook(); return hook.memoizedState;}On the other hand, useState() is a bit more complicated. On mount, it needs to:
- handle the state initialization,
- handle the dispatcher (the set state function),
- start tracking update queues.
And on update, it relies on useReducer() implementation, which I won’t go into detail, as the function is 300 LOCs long.
So in terms of branch count, function calls, and memory allocation, useRef() has significantly lower overhead than useState().
Is it relevant? I don’t know if it fully is as most React applications will be very complex. And user components will even be bigger. But if we can cut down in centralized components their memory cost, maybe at wide scale (if this useConstant() custom hook is widely used) it could have an impact.
My personal conclusion + TypeScript code snippets
Section titled “My personal conclusion + TypeScript code snippets”If you don’t need to be able to handle any undefined values, use the useRef() implementation:
function useConstant<T>(init: T extends undefined ? never : () => T): T { const ref = React.useRef<T>(undefined); if (ref.current === undefined) { ref.current = init(); } return ref.current;}But if your init function can return undefined, go with useState():
function useConstant<T>(init: () => T) { const [constant] = React.useState<T>(() => init()); return constant;}Open question: React compiler
Section titled “Open question: React compiler”When writing the implementation with the useRef():
function useConstant<T>(init: T extends undefined ? never : () => T): T { const ref = React.useRef<T>(undefined); if (ref.current === undefined) { ref.current = init(); } return ref.current;}Both the React linter and the React compiler seem to be unhappy with ref.current:
return ref.current; ^^^^^^^^^^^ Cannot access ref value during renderBased on the React documentation, this seems (for this specific example) to be okay. But just in case, I opened an issue to track it: https://github.com/react/react/issues/36896.
Depending on the React team’s answer, my recommendation may switch to just useState()’s implementation solely because of the better compatibility with the compiler.