The Issue
I have a small project that I’m starting, and I decided to try out Preact for the first time, using their TypeScript starter template. Everything installed (relatively) smoothly, and it seemed like everything was fine… until I tried to add a third-party component library. Then everything exploded.
To be specific, I tried to add Chakra UI, and immediately got this error (“cannot be used as a JSX component”) when trying to add any component:
'___' cannot be used as a JSX component.
Its return type 'ReactElement<any, any> | null' is not a valid JSX element.
Type 'ReactElement<any, any>' is not assignable to type 'Element'.ts(2786)
Trying to chase down this incompatible return type (“not a valid JSX element”) led me down a long trail, but the basics seemed to be that Preact components, such as FunctionalComponent
(used by the template’s default App.tsx
file) expect preact.VNode
elements to make up children / returned elements / etc. This makes sense; preact.VNode
is part of Preact’s VDOM implementation, which is distinctly different from React’s. It’s also worth noting where this comes from; in Preact’s JSX system, Element
(aka JSX.Element
) extends preact.VNode<any>
.
What confused me about this error was two things:
- Why is this happening? According to this page,
preact-compat
is included by default in the newer versions of Preact. And I’ve followed the one step listed in the docs – settingskipLibCheck
totrue
. - How do I fix this? Is there a way in TypeScript to override namespaces and module definitions, to the extent that it could fix third party imported code?
The Solutions
The first issue I found (react-colorful/#39) was very close to mine, but not yet solved. However, after some more digging, I finally ended up on preact #2222, #2150, and probably the most relevant, PR #2329.
🚧 Once PR #2329 is merged and released, I believe that this issue will no longer exist, and you won’t have to do any manual patching to get third party React libraries to work.
To summarize, the main issue seems to be that, although Preact is “shipping” a lot of types needed for React compatibility, they aren’t necessarily getting automatically “pulled in” for TypeScript to understand that a third-party lib’s use of JSX Element should be aliased / pointed to Preact’s implementation.
Option A – Use Path Mapping
TypeScript’s config has an incredibly powerful option that allows import paths to be remapped to destinations of our choice; the paths
option.
For our case, we can use this to remap the resolution of react
and react-dom
to the preact-compat
layer, which exposes Preact’s JSX
implementation, among other Preact types.
Here is the change to tsconfig.json
(thanks to this comment):
{
"compilerOptions": {
// Needed in most cases
"skipLibCheck": true,
// You need a value set for baseUrl
"baseUrl": ".",
// This is the key - module resolution path remapping
"paths": {
"react": ["node_modules/preact/compat/"],
"react-dom": ["node_modules/preact/compat/"]
}
}
}
⚠ Warning: If you are using a separate bundler / compiler (outside of TSC), you might also need to inform it of the path aliasing. For example, if you use Parcel.
If the types exported from preact/compat
don’t cover it for you, and you need to provide more React type shims, another variant of the above solution is to create your own compatibility declaration file (react/index.d.ts
), and use the paths
option to map to it. This approach is used in the issue #2150 thread.
Option B – Manually Override Types
I couldn’t get this working 100% (at least not without turning on skipLibCheck
), but I believe it might be possible to “monkey-patch” the definitions for the React module in-place, by using TypeScript’s module augmentation and declaration merging features.
This was my (bad) attempt, which was close to working, but violated a lot of TS rules:
import * as React from 'react';
declare module 'react' {
namespace React {
type FC<P = {}> = preact.FunctionComponent<P>;
// ...
}
export = React;
export as namespace React;
}
However, I have a feeling this is the wrong approach; namespace merging is a complicated topic with TypeScript, and it looks like the best solution for global module augmentation is to follow the pattern of:
- Creating a folder with the module name
- Creating an index file in that folder (
/{moduleName}/index.d.ts
) - Telling TSC about the file (if necessary)
Again, these steps are outlined in this comment on #2150, and partially in the TS Docs. If you can find better documentation / best practices, please let me know; this is a part of TS that I always feel I could use a better grasp of.
Thank you so much for solving my issue. This crap bugged me for the past hour or so.
Glad to have helped!
I appreciate the detailed explanation, thanks! Option 1 has solved my issue.