How to Fix 'Cannot read property props of null' in React.forwardRef with TypeScript
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins
TL;DR
- What broke:
React.forwardRefreceived anullcomponent instance — either because the wrapped component conditionally returnsnullbefore the ref attaches, or because the TypeScript generic onforwardRefwas omitted, causing the internalpropsaccess to dereference a null object at runtime. - How to fix it: Explicitly type
React.forwardRef<HTMLDivElement, YourProps>(), guard the ref callback with a null check, and ensure the wrapped component never returnsnullon the initial render path that the ref depends on. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your broken component and get a fully typed, null-safe refactor without sending your code to a third-party server.
The Incident (What does the error mean?)
The raw crash looks like this in your browser console or Node SSR log:
TypeError: Cannot read properties of null (reading 'props')
at Object.forwardRef (<anonymous>)
at renderWithHooks (react-dom.development.js:14985)
at mountIndeterminateComponent (react-dom.development.js:17811)
at beginWork (react-dom.development.js:19049)
Or in older React 17 stacks:
TypeError: Cannot read property 'props' of null
at React.forwardRef (react.development.js:471)
Immediate consequence: The entire React subtree below the offending forwardRef component is unmounted. React's error boundary (if you have one) catches it, but without one, the whole page goes blank. In SSR (Next.js, Remix), this is a 500. In a client-side SPA, it is a white screen with no fallback.
The root cause is almost always one of three things:
- Missing generic types —
React.forwardRef((props, ref) => ...)without<T, P>generics causes TypeScript to inferpropsas{}and the ref target asunknown, which collapses tonullat the point React tries to read the ref's current.props. - Conditional null return before ref attachment — The inner component returns
nullon first render (e.g., a loading gate), but the parent has already calledref.current.someMethod()in auseEffectwith an empty dependency array. - Passing a plain object as the render function — A common mistake when migrating class components: passing
{ render: (props, ref) => <div /> }instead of the render function directly.
The Attack Vector / Blast Radius
This is not a security vulnerability in the CVE sense, but the blast radius in production is severe:
- Component tree destruction: React does not partially recover from a null dereference inside
forwardRef. The fiber node is poisoned. Even if you catch it with an error boundary, the ref is permanently broken for that mount cycle — you must force a full remount via akeychange. - SSR hard crash: In Next.js
getServerSidePropsor App Router server components, this error is not caught by client-side error boundaries. It propagates to the server response and returns a 500, taking down every concurrent request being processed by that lambda/container until the process restarts. - Silent type erasure: The most dangerous variant is when TypeScript compiles successfully because
forwardRefwas called without generics. The type system silently widenspropstoany, masking the null at compile time. You only discover it in production when a specific render path hits the null branch. - Third-party component libraries: If your
forwardRefwraps a third-party component (MUI, Radix, Headless UI) and that library's ref typing is mismatched with your wrapper's generic, the error surfaces in the library's internals — making it look like a library bug when it is your wrapper.
How to Fix It (The Solution)
Basic Fix: Add Explicit Generics and a Null Guard
The minimal fix is to type the forwardRef call explicitly and guard the render function's ref parameter.
- const MyInput = React.forwardRef((props, ref) => {
- return <input ref={ref} {...props} />;
- });
+ interface MyInputProps {
+ placeholder?: string;
+ disabled?: boolean;
+ }
+
+ const MyInput = React.forwardRef<HTMLInputElement, MyInputProps>(
+ (props, ref) => {
+ return <input ref={ref} {...props} />;
+ }
+ );
+
+ MyInput.displayName = 'MyInput';
Enterprise Best Practice: Full Null-Safe forwardRef Pattern
In a production codebase with strict TypeScript ("strict": true in tsconfig), use this pattern. It handles the conditional-null-return case, provides a safe imperative handle via useImperativeHandle, and is fully compatible with React 18's concurrent rendering.
- // BROKEN: No generics, no null guard, no displayName
- const FancyButton = React.forwardRef((props, ref) => {
- if (!props.isVisible) return null; // ref is attached AFTER this returns null
- return (
- <button ref={ref} className={props.className}>
- {props.children}
- </button>
- );
- });
+ // FIXED: Explicit generics, visibility via CSS not conditional null,
+ // useImperativeHandle for safe method exposure
+ import React, {
+ forwardRef,
+ useImperativeHandle,
+ useRef,
+ ForwardedRef,
+ } from 'react';
+
+ interface FancyButtonProps {
+ isVisible?: boolean;
+ className?: string;
+ children: React.ReactNode;
+ onClick?: () => void;
+ }
+
+ export interface FancyButtonHandle {
+ focus: () => void;
+ scrollIntoView: () => void;
+ }
+
+ const FancyButton = forwardRef<FancyButtonHandle, FancyButtonProps>(
+ (
+ { isVisible = true, className, children, onClick }: FancyButtonProps,
+ ref: ForwardedRef<FancyButtonHandle>
+ ) => {
+ const buttonRef = useRef<HTMLButtonElement>(null);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ focus: () => buttonRef.current?.focus(),
+ scrollIntoView: () =>
+ buttonRef.current?.scrollIntoView({ behavior: 'smooth' }),
+ }),
+ []
+ );
+
+ // Use CSS visibility instead of conditional null return
+ // so the ref is ALWAYS attached to the DOM node
+ return (
+ <button
+ ref={buttonRef}
+ className={className}
+ onClick={onClick}
+ style={{ visibility: isVisible ? 'visible' : 'hidden' }}
+ aria-hidden={!isVisible}
+ >
+ {children}
+ </button>
+ );
+ }
+ );
+
+ FancyButton.displayName = 'FancyButton';
+ export default FancyButton;
Why visibility: hidden instead of return null?
When a component returns null, React unmounts the DOM node. Any ref pointing to that node becomes null. If a parent's useEffect or imperative code then calls ref.current.focus(), you get exactly this crash. Using CSS visibility keeps the DOM node mounted and the ref valid at all times.
Why useImperativeHandle?
It decouples the internal DOM ref from the external ref handle. The parent only gets the methods you explicitly expose. If the internal ref is ever null (e.g., during an async transition), the optional chaining ?. in the handle methods prevents the crash from propagating.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
This class of error is 100% preventable at the PR stage. Wire up the following:
1. ESLint: @typescript-eslint/no-unsafe-member-access + react/display-name
Add to your .eslintrc.json:
"rules": {
+ "@typescript-eslint/no-unsafe-member-access": "error",
+ "@typescript-eslint/no-unsafe-assignment": "error",
+ "react/display-name": "error"
}
react/display-name will error on any forwardRef call that lacks a .displayName assignment — a strong proxy for "this forwardRef was typed carelessly."
2. TypeScript strict Mode in tsconfig
{
"compilerOptions": {
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true
}
}
Without strict: true, TypeScript will not complain about an untyped forwardRef call. This is the single most impactful tsconfig change you can make.
3. Pre-commit Hook with tsc --noEmit
+ # .husky/pre-commit
+ #!/bin/sh
+ npx tsc --noEmit
+ npx eslint . --ext .ts,.tsx --max-warnings 0
This blocks the commit if TypeScript reports any errors, including the implicit any on an untyped forwardRef.
4. GitHub Actions Type-Check Job
+ jobs:
+ typecheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ - run: npm ci
+ - run: npx tsc --noEmit
+ - run: npx eslint . --ext .ts,.tsx --max-warnings 0
Make this a required status check on your main branch. No merge without a clean type-check pass.