Next.js

Cannot assign to 'current' (read-only): useRef TS2540 fix

Two characters of parentheses decide whether TypeScript hands back a mutable ref or a read-only one. Here is how to read the overloads and fix the ref in place.

Mahdi Ben RhoumaMahdi Ben Rhouma
7 min read

TL;DR#

On React 18 type definitions, useRef<T>(null) returns a RefObject whose current is readonly whenever null is not part of T. Writing to it then fails with TS2540. In the case from the original Stack Overflow question, useRef<() => void | null>(null) looks like it includes null, but the | null binds to the function's return type, not to the ref. The fix is one pair of parentheses: useRef<(() => void) | null>(null). For DOM refs you assign yourself, write useRef<HTMLInputElement | null>(null).

The error, as tsc prints it:

text
src/App.tsx(9,16): error TS2540: Cannot assign to 'current' because it is a read-only property.
src/Input.tsx(7,14): error TS2540: Cannot assign to 'current' because it is a read-only property.

Both lines come from a reproduction run on 10 October 2026 in the official node:22-alpine Docker image, with TypeScript 7.0.2, @types/react 18.3.31 and strict: true. TypeScript 5.9.3 prints the same two lines. The rest of this page uses that run.

The two files that trigger it#

The first file is the question's component, trimmed to the lines that matter:

tsx
import { useRef, useEffect } from "react";
 
export default function App() {
  const testRef = useRef<string | null>(null);
  const unblockRef = useRef<() => void | null>(null);
 
  useEffect(() => {
    testRef.current = "";
    unblockRef.current = null;
  }, []);
 
  return <h1>Hello</h1>;
}

Line 8 (testRef.current = "") compiles. Line 9 (unblockRef.current = null) does not. That contrast is the question's whole puzzle: both refs are declared with | null, both start at null, and only one of them is writable.

The second file is the shape most people actually hit, a DOM ref that the component also resets by hand:

tsx
import { useRef } from "react";
 
export function Field() {
  const inputRef = useRef<HTMLInputElement>(null);
 
  function reset() {
    inputRef.current = null;
  }
 
  return <input ref={inputRef} onBlur={reset} />;
}

Why current ends up read-only#

The React 18 type definitions declare three overloads of useRef. You can read them in @types/react 18.3.31:

ts
function useRef<T>(initialValue: T): MutableRefObject<T>;
function useRef<T>(initialValue: T | null): RefObject<T>;
function useRef<T = undefined>(initialValue?: undefined): MutableRefObject<T | undefined>;

MutableRefObject<T> has a plain current: T. RefObject<T> declares readonly current: T | null. The comment on the second overload in the same file says how to avoid it: "if you need the result of useRef to be directly mutable, include | null in the type of the generic argument."

With strict on, null is only assignable to T when T contains null. So:

  • useRef<string | null>(null): null fits T, the first overload matches, you get a MutableRefObject, and assignment works.
  • useRef<HTMLInputElement>(null): null does not fit T, so TypeScript falls through to the second overload and returns a read-only RefObject.

The question's function ref falls into the second case for a less obvious reason. In a type, the arrow of a function type takes everything to its right, so () => void | null means "a function that returns void | null", as the accepted answer on the question explains. The ref type contains no null, the first overload fails, and the second one wins.

The reproduction makes this visible. Assigning each ref to a number forces the compiler to print the object type it inferred:

text
src/Probe.ts(5,9): error TS2322: Type 'RefObject<() => void | null>' is not assignable to type 'number'.
src/Probe.ts(6,9): error TS2322: Type 'MutableRefObject<(() => void) | null>' is not assignable to type 'number'.

Line 5 is the question's spelling and gets the read-only RefObject. Line 6 adds parentheses and gets the mutable MutableRefObject. React's own types rely on the same precedence on purpose: @types/react 18.3.31 declares type EffectCallback = () => void | Destructor;, a function that returns either nothing or a cleanup function.

Fix: put null in the ref type, not in the return type#

Change one thing at a time and recompile after each change, so you know which edit fixed which error.

  1. Wrap the function type in parentheses so that | null applies to the ref:
tsx
const unblockRef = useRef<(() => void) | null>(null);
  1. Recompile. Only the DOM-ref error is left:
text
src/Input.tsx(7,14): error TS2540: Cannot assign to 'current' because it is a read-only property.
  1. For a DOM ref that your own code writes to, add | null to the element type:
tsx
const inputRef = useRef<HTMLInputElement | null>(null);

The <input ref={inputRef} /> prop still type-checks, because a MutableRefObject<HTMLInputElement | null> is accepted wherever React expects a ref to that element.

If your code only reads a DOM ref (inputRef.current?.focus()), useRef<HTMLInputElement>(null) is fine as it is: the read-only current only gets in the way of assignments. A second answer on the question suggests annotating the variable as React.MutableRefObject<...> explicitly. That works on React 18 types, but the | null in the type argument already selects the same overload, and the annotation has to be removed later (see the React 19 section below).

Verify the fix#

Run the type checker on the whole project, not only on the file you edited:

bash
npx tsc --noEmit

After both edits, the reproduction project compiled cleanly and tsc exited with code 0. On the original files, TypeScript 7.0.2 exited with code 1 on the two errors and TypeScript 5.9.3 exited with code 2. A CI step that tests for one specific non-zero code should test for "not zero" instead.

In an editor, hover over the ref variable. MutableRefObject<...> (React 18 types) means the assignment is allowed. RefObject<...> with a readonly current means it is not.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

After a React 19 upgrade the error disappears, and a different one appears#

The same two original files compile without a single error once the project uses @types/react 19.3.0. The React 19 upgrade guide describes the change: "This now also means that all refs are mutable." In the 19 type definitions, RefObject<T> declares current: T with no readonly, useRef always returns it, and MutableRefObject carries a @deprecated tag that says to use RefObject instead. The guide links the DefinitelyTyped RFC that made all refs mutable.

Two things change for you.

First, the precedence mistake does not go away just because the error does. On 19.3.0 the probe printed:

text
src/Probe.ts(5,9): error TS2322: Type 'RefObject<(() => void | null) | null>' is not assignable to type 'number'.

The ref is now writable, but its type still says the stored function returns void | null. Keep the parentheses: useRef<(() => void) | null>(null) describes what you actually store.

Second, useRef now requires an argument. A timer ref written as useRef<number>() matched the third overload on React 18 types, whose only parameter is optional. After the upgrade it fails:

text
src/Probe.ts(3,17): error TS2554: Expected 1 arguments, but got 0.

Pass the initial value explicitly, useRef<number | undefined>(undefined), or use useRef<number | null>(null) if the rest of the code compares against null. The types-react-codemod transform useRef-required-initial, part of its React 19 preset, rewrites useRef() to useRef(undefined) for you.

For a Next.js project, this is when the switch happens: the Next.js 15 upgrade guide notes that "If you are using TypeScript, ensure you also upgrade @types/react and @types/react-dom to their latest versions." Upgrade the types together with the React 19 runtime. Bumping @types/react alone on a React 18 app only hides TS2540 behind types that describe a different React version, so fix the type arguments as shown above in either case.

Where these refs usually live#

The question's unblockRef is assigned inside a useEffect, the classic place for a ref that holds an unsubscribe or unblock callback. For the extra setup cycle under Strict Mode in development, see why useEffect runs twice and how to handle it. If the ref was introduced as an isMounted flag, the unmounted-component warning explanation distinguishes persistent subscription leaks from finite Promise completion and shows cancellation or effect-local stale-result guards.

TS2540 is one of several errors where the fix is a more precise type rather than a cast. TS7053 when indexing an object with a string is fixed the same way, by narrowing the key with keyof typeof or typing the object as a Record. TS2564 on class fields is another case where the right fix depends on why the value can be missing.

In short, the compiler is right in both cases. A ref typed without null but started at null is one that React fills in, and a function type that lists | null after its arrow is describing what the function returns. Put the null where you mean it, re-run npx tsc --noEmit, and the assignment compiles on React 18 types and stays correctly typed after the move to React 19.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote