JSX.Element vs ReactNode vs ReactElement: TS2322 Fix
Fix TS2322 by understanding when ReactNode, JSX.Element, and ReactElement apply in React + TypeScript component typing.
Photo by Pankaj Patel on Unsplash
TL;DR#
If you’re seeing TS2322: Type 'null' is not assignable to type 'JSX.Element' or Type 'string' is not assignable to type 'JSX.Element', the root cause is usually using JSX.Element where ReactNode is required — especially in component return types or props that accept flexible content like children or render props.
Fix it by replacing JSX.Element with ReactNode for return types and props that need to support null, strings, numbers, or arrays — and reserve JSX.Element for strict JSX-only contexts like render props where you know only JSX will be passed.
If that doesn’t work, check whether you’re accidentally using ReactElement (a runtime object type) where ReactNode (a renderable value type) is expected.
The error#
You’ll see errors like these in your editor or terminal:
TS2322: Type 'null' is not assignable to type 'JSX.Element'.
TS2322: Type 'string' is not assignable to type 'JSX.Element'.
TS2322: Type 'Element' is not assignable to type 'JSX.Element'.
TS2322: Type 'ReactElement<...>' is not assignable to type 'JSX.Element'.It happens when you type a component’s return value, a prop (like children, render, or fallback), or a render prop function as JSX.Element — but then return or pass something like null, a plain string, a number, or an array of elements.
Why JSX.Element is too narrow#
TypeScript’s React types are layered, and the confusion comes from conflating what JSX compiles to with what React can render. Let’s untangle them.
JSX.Element is the type that JSX expressions (like <div />) are inferred to have before React transforms them. In older React versions, JSX.Element was defined by @types/react as ReactElement<any, any>. But in modern React (18+), JSX.Element is intentionally kept narrow: it only covers valid JSX syntax, not arbitrary renderable values.
ReactElement, on the other hand, is the actual runtime object created by React.createElement(). It’s a concrete type with properties like type, props, and key. You rarely need to use it directly — and using it as a prop type is almost always wrong.
ReactNode is the union type React uses internally to define what can be rendered: ReactNode = ReactElement | string | number | boolean | null | undefined | ReactNode[] | Portal | Fragment. It’s the correct type for component return values and most props that accept content.
// src/components/Card.tsx
import React from 'react';
// ❌ Wrong: JSX.Element excludes null, strings, numbers, arrays
function Card({ title, content }: { title: string; content: string }) {
if (!content) {
return null; // ← TS2322: Type 'null' is not assignable to type 'JSX.Element'
}
return (
<div>
<h2>{title}</h2>
<p>{content}</p>
</div>
);
}
// ❌ Also wrong: JSX.Element doesn’t accept arrays of elements
function List({ items }: { items: string[] }) {
return items.map((item) => <li key={item}>{item}</li>); // ← TS2322
}TypeScript rejects null and arrays here because JSX.Element is not a superset of ReactNode. It’s a subset — and only covers single, non-null JSX elements.
The switch#
Replace JSX.Element with ReactNode in return types and props that need flexibility. Reserve JSX.Element only for cases where you explicitly want to forbid non-JSX values (like render props that must return JSX).
// src/components/Card.tsx
import React from 'react';
// ✅ Correct: ReactNode accepts null, strings, numbers, arrays
function Card({ title, content }: { title: string; content: string }) {
if (!content) {
return null;
}
return (
<div>
<h2>{title}</h2>
<p>{content}</p>
</div>
);
}
// ✅ Also correct: ReactNode handles arrays naturally
function List({ items }: { items: string[] }) {
return items.map((item) => <li key={item}>{item}</li>);
}
// ✅ For render props: use JSX.Element *only* if you want JSX-only
function Modal({
renderHeader,
}: {
renderHeader: () => JSX.Element; // ← strict JSX-only
}) {
return <div>{renderHeader()}</div>;
}
// ✅ For flexible content (like children): use ReactNode
function Box({ children }: { children: React.ReactNode }) {
return <div className="box">{children}</div>;
}That single change addresses the cause because ReactNode is the official type React uses to define what can be rendered — it’s designed to match runtime behavior, not just JSX syntax.
Step by step#
- Open the file where you see the TS2322 error.
- Find the component or function with the incorrect type (e.g.,
(): JSX.Elementor{ render: JSX.Element }). - Replace
JSX.ElementwithReactNodein return types and props likechildren,fallback,empty, orrender. - Save and run
tsc --noEmitto confirm no errors.
Component props#
Typing component props is the most common place developers reach for JSX.Element and immediately hit TS2322. A prop describes a slot the consumer fills — and that slot may legitimately receive null, a string, a number, an array, or a fragment, not just a single JSX element.
The pattern to internalize: when a prop represents renderable content, type it as ReactNode. When it represents a function that must produce JSX, type it as JSX.Element.
// Common component props patterns
// ✅ Content slot: ReactNode
type ContainerProps = {
children: React.ReactNode;
};
// ✅ Optional sections: ReactNode (not JSX.Element)
type CardProps = {
header?: React.ReactNode;
footer?: React.ReactNode;
body: React.ReactNode;
};
// ✅ Render prop producing JSX: JSX.Element enforces a JSX-only return
type TableProps<T> = {
items: T[];
renderRow: (item: T) => JSX.Element;
renderEmpty: () => JSX.Element;
};
// ✅ Render prop that may return null/string: ReactNode
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
renderEmpty?: () => React.ReactNode;
};
// ❌ Common mistake: JSX.Element on a content prop
type BadProps = {
icon: JSX.Element; // ← rejects strings like icon="save", and null
};Rules of thumb for component props:
childrenand any "slot" prop (header,footer,body,fallback,empty,overlay,tooltip): useReactNode. Optional slots should beReactNode | undefined(or justReactNode— it already includesundefined).- Render props where the consumer must return JSX (
renderRow,renderHeader,renderCell): useJSX.Elementto enforce a strict JSX return. - Render props that may return a string, number, or
null(renderLabel,renderEmpty): useReactNode. - Avoid
ReactElementas a prop type. It’s a runtime object shape ({ type, props, key }) — consumers don’t think in those terms, and it’s not assignable from arbitrary JSX in modern React.
If you’re publishing a component library, defaulting to ReactNode for every content prop is the most forgiving and version-stable choice. It accepts everything a React render can produce, and you won’t break consumers when they pass a conditional null or a fragment.
Function return types#
The second hot spot is function return types — both for React component functions and for helper functions that build JSX. The error pattern is always the same: a function is annotated as (): JSX.Element (or : ReactElement<...>), but the body returns null, a primitive, or an array.
The correct function return type depends on what the function is allowed to produce:
import React, { ReactNode, ReactElement } from 'react';
// ✅ React component: ReactNode covers every legal render output
function Greeting({ name }: { name?: string }): React.ReactNode {
if (!name) return null; // valid — ReactNode includes null
if (name === 'admin') return `Welcome, ${name}`; // valid — string
return <h1>Hello, {name}</h1>; // valid — JSX
}
// ❌ Component returning null with JSX.Element annotation
function Greeting({ name }: { name?: string }): JSX.Element {
if (!name) return null; // ← TS2322
return <h1>Hello, {name}</h1>;
}
// ✅ Helper that always returns a single element: JSX.Element is fine
function Spinner({ size = 16 }: { size?: number }): JSX.Element {
return <div className="spinner" style={{}} />;
}
// ❌ Helper that may return null: use ReactNode
function MaybeSpinner({ loading }: { loading: boolean }): ReactNode {
if (!loading) return null;
return <Spinner />;
}
// ❌ Don't use ReactElement as a return type — it's an object shape, not a renderable value
function Broken(): ReactElement {
return <div />; // not assignable from arbitrary JSX in modern React
}Best practices for function return types:
- React component functions: annotate as
ReactNode, or omit the annotation entirely and let TypeScript infer it. Inference usually picks the right type and adapts if you change the return. - Helper functions that always return a single element:
JSX.Elementis appropriate because you control the return and the narrowness is desirable. - Helper functions that may return
null, a string, or a number: useReactNode. - Render prop functions: choose
JSX.Elementif you require JSX,ReactNodeif you permit non-JSX returns. - Avoid
ReactElementas a return type on components or helpers. It’s a concrete object type, not a renderable value type, and it doesn’t match what JSX expressions produce in modern React.
A useful shortcut: in most cases, you can simply omit the return type annotation on React components. TypeScript will infer a JSX.Element for the happy path and null/undefined for early returns, and the resulting inferred type is structurally compatible with ReactNode. The annotation only matters when you want to constrain what the function may return — which is exactly the situation where ReactNode (broad) or JSX.Element (narrow) gives you the behavior you want.
Check it#
tsc --noEmitIf you previously had TS2322 on null, string, or array returns, those are gone now.
If you’re still seeing errors, two common variants exist:
Variant A — Using ReactElement instead of ReactNode#
You might see:
TS2322: Type 'Element' is not assignable to type 'ReactElement'.This happens when you type a prop as ReactElement but pass JSX (which TypeScript infers as Element, not ReactElement). Fix it by switching to ReactNode — or, if you must use ReactElement, explicitly cast with as ReactElement (but avoid this unless you control the source).
Variant B — Strict JSX mode in Next.js or Vite#
In some setups (like Next.js 13+ with the "jsx": "react-jsx" transform), JSX.Element is aliased to React.JSX.Element, which is slightly different from React.JSX.Element in older versions. If you’re mixing libraries (e.g., React 18 + old @types/react), you might get TS2322 on valid JSX.
Fix it by ensuring @types/react and react versions match (e.g., both v18.3+), and use ReactNode universally — it’s the most stable cross-version choice.
Going forward#
The confusion is mostly historical: early React docs leaned on ReactElement loosely, and JSX.Element got cargo-culted as a “safe” type. ReactNode is the right one for almost everything — it’s what React.FC used to use (though React.FC itself is now discouraged).
Rules to stop reaching for the wrong type:
- For return types: Always use
ReactNode(or omit the return type entirely — TypeScript infers it correctly). - For props: Use
ReactNodeforchildren,fallback,empty,render,childrenRenderer, etc. UseJSX.Elementonly for render props where you want to enforce JSX-only input (e.g.,<DataTable renderHeader={() => <h1 />} />). - For libraries: If you’re publishing a component library, explicitly type props with
ReactNode— it’s the most compatible choice across React versions.
I cover this in detail in JS to TypeScript: Incremental Migration, No Full Rewrite, where we refactor legacy React components without breaking existing behavior.
Related#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Fix "Property does not exist on Window" in TypeScript
Learn how to safely extend the Window interface in TypeScript using declaration merging, type assertions, and bracket notation to avoid compile-time errors.
Force tsc to Ignore node_modules: Fix TS Errors
Run tsc --noEmit on a project that compiles fine in the editor and the terminal lights up with type errors from inside node_modules. The fix is skipLibCheck, plus two
Fix TS2305: Module Has No Exported Member in TypeScript
TS2305 says the export you're importing doesn't exist under that name. The cause is almost always one of: a typo, default-vs-named confusion, a CJS/ESM interop mismatch, or @types drifting from the runtime package. Each has a precise fix.
Browse by Topic
Find stories that matter to you.
