TypeScript

JSX.Element vs ReactNode vs ReactElement: TS2322 Fix

Fix TS2322 by understanding when ReactNode, JSX.Element, and ReactElement apply in React + TypeScript component typing.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
9 min read

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:

plaintext
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. Checked directly against the current @types/react source on DefinitelyTyped, JSX.Element is defined as interface Element extends React.ReactElement<any, any> {} — it has always been exactly ReactElement<any, any>, not a type that got "narrowed" in a later React version. What narrows it is simpler: ReactElement itself has never included null, strings, numbers, or arrays, so neither does JSX.Element.

ReactElement, on the other hand, is the TypeScript interface describing the 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, since props typed ReactElement reject the same null/string/number/array values that JSX.Element rejects.

ReactNode is the union type React uses internally to define what can be rendered. In the current @types/react source it's ReactElement | string | number | bigint | Iterable<ReactNode> | ReactPortal | boolean | null | undefined (plus an Awaited<ReactNode>-flavoured branch for async Server Components) — in short, every legal render output, not just a single JSX element. It’s the correct type for component return values and most props that accept content — the official React TypeScript guide recommends ReactNode for a children prop precisely because of this broad definition, in contrast to the narrower ReactElement.

tsx
// src/components/Card.tsx
import React from 'react';
 
// ❌ Wrong: JSX.Element excludes null, strings, numbers, arrays
function Card({ title, content }: { title: string; content: string }): React.JSX.Element {
  if (!content) {
    return null; // ← TS2322: Type 'null' is not assignable to type '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[] }): React.JSX.Element {
  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).

tsx
// src/components/Card.tsx
import React, { ReactNode } from 'react';
 
// ✅ Correct: ReactNode accepts null, strings, numbers, arrays
function Card({ title, content }: { title: string; content: string }): ReactNode {
  if (!content) {
    return null;
  }
 
  return (
    <div>
      <h2>{title}</h2>
      <p>{content}</p>
    </div>
  );
}
 
// ✅ Also correct: ReactNode handles arrays naturally
function List({ items }: { items: string[] }): ReactNode {
  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#

  1. Open the file where you see the TS2322 error.
  2. Find the component or function with the incorrect type (e.g., (): JSX.Element or { render: JSX.Element }).
  3. Replace JSX.Element with ReactNode in return types and props like children, fallback, empty, or render.
  4. Save and run tsc --noEmit to 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.

tsx
// 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:

  • children and any "slot" prop (header, footer, body, fallback, empty, overlay, tooltip): use ReactNode. Optional slots should be ReactNode | undefined (or just ReactNode — it already includes undefined).
  • Render props where the consumer must return JSX (renderRow, renderHeader, renderCell): use JSX.Element to enforce a strict JSX return.
  • Render props that may return a string, number, or null (renderLabel, renderEmpty): use ReactNode.
  • Avoid ReactElement as a prop type. It’s a runtime object shape ({ type, props, key }) — consumers don’t think in those terms, and (being ReactElement, same as JSX.Element) it rejects null, strings, numbers, and arrays just as JSX.Element does.

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:

tsx
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 GreetingStrict({ 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, not JSX.Element
function MaybeSpinner({ loading }: { loading: boolean }): ReactNode {
  if (!loading) return null;
  return <Spinner />;
}
 
// ❌ Don't use ReactElement as a return type on components — it's a concrete
// object shape, not idiomatic for rendering, even though JSX does structurally fit it
function Broken(): ReactElement {
  return <div />;
}

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.Element is appropriate because you control the return and the narrowness is desirable.
  • Helper functions that may return null, a string, or a number: use ReactNode.
  • Render prop functions: choose JSX.Element if you require JSX, ReactNode if you permit non-JSX returns.
  • Avoid ReactElement as a return type on components or helpers. It’s a concrete object type, not a renderable value type — it technically accepts the JSX you return (JSX.Element is itself ReactElement<any, any>), but it carries the same null/string/array gaps as JSX.Element with none of its readability.

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#

bash
tsc --noEmit

If 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 — Duplicate or mismatched @types/react copies#

You might see something like:

plaintext
Type 'Element' is not assignable to type 'ReactElement'.
  Two different types with this name exist, but they are unrelated.

Checked against a current tsc run, passing plain JSX to a prop or return typed ReactElement does not fail on its own — JSX.Element is ReactElement<any, any>, so it's structurally the same thing. This error instead comes from having two separate installs of @types/react in node_modules (common in monorepos, or when a dependency pins its own @types/react), so TypeScript sees two distinct ReactElement/JSX.Element declarations that happen to share a name. Fix it with npm ls @types/react to find the duplicate, then dedupe it (or add a single pinned version via your package manager's overrides/resolutions field) rather than casting around it.

Variant B — Bare JSX.Element with current @types/react#

As of @types/react 19 (the version shipping with React 19), the ambient global JSX namespace that older @types/react declared was removed in favour of React.JSX. Confirmed with a tsc --noEmit run against @types/react@19.3.0: a bare JSX.Element return type — even with import React from 'react' already in scope — now fails with TS2503: Cannot find namespace 'JSX', regardless of the jsx transform setting.

Fix it by qualifying the type as React.JSX.Element, or by importing it explicitly: import type { JSX } from 'react'. If you're on an older @types/react (18.x) the bare JSX.Element form still works, which is why this only shows up after an upgrade.

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 ReactNode for children, fallback, empty, render, childrenRenderer, etc. Use JSX.Element only 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.

Further reading: How to Convert a String to Number in TypeScript: 2026 Fix, TypeScript ! Operator: Fix Object Is Possibly Undefined.

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

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.