Skip to content

React: how to define a constant variable

7 min read • Posted on June 30, 2026

#react

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 like WeakMap
  • Generating a new instance of a class, like new MutationObserver(), or defining a web worker new 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
}
// ...
}

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 😄.

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.

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.

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.current manually (without realizing, like if you used the wrong ref).

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.current during 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).

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.

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());

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 useRef
const useConstant = (init) => {
const constantRef = React.useRef();
if (constantRef.current === undefined) {
constantRef.current = init();
}
return constantRef.current;
};
// Option B: with useState
const useConstant = (init) => {
const [constant] = React.useState(() => init());
return constant;
};

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 some
useConstantBad(() => undefined);
useConstantBad(() => potentiallyUndefined);
// ✅ – Does throw errors, even for variables that can be something or undefined
useConstantGood(() => undefined);
useConstantGood(() => potentiallyUndefined);

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;
}

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 render

Based 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.