The main purpose of this project based on one of the lessons in the course "React - The Complete Guide"1 by Maximilian Schwarzmuller is to practice TypeScript. In addition Context API, useReducer and Redux, three popular ways of state managment.
Define `{...props}` type
I spent quite a lot of time trying to solve this doubt on how to type the spread property of a component what is shown in the next snippet:
interface InputProps {
isTextarea: boolean,
label: string,
props: // Which type is this?
}
export default function input({ isTextarea, label, ...props }: InputProps)
{
return (
<p>
<label htmlFor="">{label}</label>
{isTextarea ? <textarea {...props} /> : <input {...props} />}
</p>
)
}
For the props field in the InputProps interface, we want to account for the different props accepted by <textarea>
and <input>
. Since textarea and input elements share many props but also have unique ones, we can use TypeScript's built-in utility types.
We can use a discriminated union to conditionally handle the props depending on the isTextarea flag. Here's how:
import React from "react";
interface InputPropsBase {
label: string;
}
interface InputPropsTextArea extends InputPropsBase {
isTextarea: true;
props?: React.TextareaHTMLAttributes<HTMLTextAreaElement>;
}
interface InputPropsInput extends InputPropsBase {
isTextarea: false;
props?: React.InputHTMLAttributes<HTMLInputElement>;
}
type InputProps = InputPropsTextArea | InputPropsInput;
-
Base Properties:
- The label property is common to both cases, so it's extracted into a base interface InputPropsBase.
-
Conditional Props:
- InputPropsTextArea: Includes isTextarea: true and allows
React.TextareaHTMLAttributes<HTMLTextAreaElement> as props
. - InputPropsInput: Includes isTextarea: false and allows
React.InputHTMLAttributes<HTMLInputElement> as props
.
- InputPropsTextArea: Includes isTextarea: true and allows
-
Discriminated Union:
- Using
isTextarea
as the discriminator ensures that TypeScript will enforce the correct props type based on its value.
- Using
-
Default Props:
- Added
props = {}
to avoid undefined props when spreading.
- Added
However the spread operator (...props)
does not automatically narrow the type of props to either React.TextareaHTMLAttributes<HTMLTextAreaElement>
or React.InputHTMLAttributes<HTMLInputElement>
based on isTextarea
. Like:
export default function Input({ isTextarea, label, props = {} }: InputProps) {
return (
<p>
<label htmlFor="">{label}</label>
{isTextarea ? (
<textarea {...props} /> //This going to cause a mismatch
) : (
<input {...props} /> //This going to cause a mismatch
)}
</p>
);
}
It's going to attempt to assign the full union of both types to each element, causing a mismatch for event handlers like onChange
.
We need to narrow the type explicitly before spreading props.
export default function Input({ isTextarea, label, props = {} }: InputProps) {
if (isTextarea) {
// Narrow to TextArea props
const textareaProps = props as React.TextareaHTMLAttributes<HTMLTextAreaElement>;
return (
<p>
<label htmlFor="">{label}</label>
<textarea {...textareaProps} />
</p>
);
} else {
// Narrow to Input props
const inputProps = props as React.InputHTMLAttributes<HTMLInputElement>;
return (
<p>
<label htmlFor="">{label}</label>
<input {...inputProps} />
</p>
);
}
}
-
Explicit Type Narrowing:
Before spreading props, explicitly cast props to the correct type (TextareaHTMLAttributes
orInputHTMLAttributes
) using aconst
assignment.This ensures TypeScript knows the exact type of props when spreading into the respective element. -
Union Resolution:
The conditionalif (isTextarea)
ensures TypeScript understands which branch is active, allowing us to safely narrow props. -
Safe Spreading:
After narrowing, spreadingtextareaProps
orinputProps
will no longer throw type errors, as their types align perfectly with the attributes of<textarea>
and<input>
respectively.
TypeScript's type narrowing requires clear distinctions in code flow, and unions don’t automatically propagate to props when destructuring. By explicitly casting and separating the logic, we ensure correctness.
More on spread props
import React from "react";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
}
export default function Button({ label, ...props }: ButtonProps) {
return (
<button {...props}>
{label}
</button>
);
}
By extending React.ButtonHTMLAttributes<HTMLButtonElement>
, the Button component automatically supports all valid attributes of a <button>
, such as onClick, disabled, type, etc.
-
TypeScript ensures that:
onClick
is properly typed as
(event: React.MouseEvent<HTMLButtonElement>) => void.
- Other invalid attributes are caught. For example, passing an invalid attribute like rows to a
<button>
would result in an error:
<Button label="Invalid Button" rows={3} /> // ❌ Error: 'rows' does not exist on type 'ButtonHTMLAttributes<HTMLButtonElement>'
- onClick is an intrinsic attribute of
<button>
, and we don’t need to define it explicitly in our interface when extendingReact.ButtonHTMLAttributes<HTMLButtonElement>
. - Using TypeScript’s intrinsic attributes for HTML elements ensures our props are aligned with the standard DOM attributes.
- Semantic Clarity:
- label explicitly communicates that the string is the button's text content.
- children is more generic and implies flexibility (e.g., the ability to nest other components).
- Consistency:
- If your component has other structured props (like icon, variant, etc.), using label keeps the API clear and avoids ambiguity:
<Button label="Click Me" icon={<Icon />} variant="primary" />;
- Flexibility for Other Features:
- If we later decide to allow additional customizations (like an optional icon or aria-label for accessibility), having a dedicated label makes it easier to manage:
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string; // Text shown on the button
icon?: React.ReactNode; // Optional icon to display
}
<Button label="Click Me" icon={<Icon />} />;
Using children:
<Button onClick={() => alert("Clicked!")}>Click Me</Button>;
Using label:
<Button onClick={() => alert("Clicked!")} label="Click Me" />;
Both work, but the second option (label) is more explicit for text-only buttons.
Typing forwardRef in React
So starting with this part of the Input component:
const Input = React.forwardRef(function Input({ isTextarea, label, props = {} }: InputProps, ref) {
//code...
<textarea ref={ref} className={classesInput} {...textareaProps} />
//more code...
<input ref={ref} className={classesInput} {...inputProps} />
})
To properly type our React.forwardRef
function for Input
, we need to handle the ref argument and its typing. Since ref will either point to a textarea or an input element based on the isTextarea
prop, you'll need to define a generic type that accommodates both.
Here’s the updated and typed React.forwardRef
implementation:
// models.read-the-docs
interface InputPropsBase {
label: string;
}
interface InputPropsTextArea extends InputPropsBase {
isTextarea: true;
props?: React.TextareaHTMLAttributes<HTMLTextAreaElement>;
}
interface InputPropsInput extends InputPropsBase {
isTextarea: false;
props?: React.InputHTMLAttributes<HTMLInputElement>;
}
type InputProps = InputPropsTextArea | InputPropsInput;
// Input.tsx
const Input = React.forwardRef<
HTMLTextAreaElement | HTMLInputElement,
InputProps
>(function Input({ isTextarea, label, props = {} }: InputProps, ref) {
//code...
<textarea
ref={ref as React.Ref<HTMLTextAreaElement>}
className={classesInput}
{...textareaProps}
/>
//more code...
<input
ref={ref as React.Ref<HTMLInputElement>}
className={classesInput}
{...inputProps}
/>
})
- ForwardRef Type:
- The
React.forwardRef
generic type is defined as<HTMLTextAreaElement | HTMLInputElement, InputProps>
. - This ensures the ref can point to either an HTMLTextAreaElement or an
HTMLInputElement
, based onisTextarea
.
- The
- Casting ref:
- Inside the conditional branches, the ref is cast to the appropriate type using
React.Ref<HTMLTextAreaElement>
orReact.Ref<HTMLInputElement>
.
- Inside the conditional branches, the ref is cast to the appropriate type using
- Fallback for props:
- The props property in InputProps is still optional and defaults to an empty object ({}).
When consuming the Input component with a ref, TypeScript will correctly infer the type based on the isTextarea prop:
// NewProject.tsx
import React from "react";
import Input from "./Input";
export default function NewProject() {
constefining Dtype = in Modal component React.useRef<HTMLInputElement>(null);
const description = React.useRef<HTMLTextAreaElement>(null);
const dueDate = React.useRef<HTMLInputElement>(null);
return (
// code component
<Input
ref={title}
isTextarea={false}
label="Title"
props={{ type: "text", placeholder: "Enter text" }}
/>
<Input
ref={description}
label="Description"
props={{ placeholder: "Enter your description" }}
isTextarea
/>
<Input
ref={dueDate}
isTextarea={false}
label="Due Date"
props={{ type: "date" }}
/>
//more code...
)
}
specific type of the dialog ref
//Modal.tsx
//code of the component
open() {
if (dialog.current !== undefined) dialog.current.showModal(); // ❌Error: Property 'showModal' does not exist on type 'never'.ts(2339)
}
//some more code..
<dialog ref={dialog}>{children}</dialog>, // ❌Error: Type 'MutableRefObject<undefined>' is not assignable to type 'LegacyRef<HTMLDialogElement> | undefined'. Type 'MutableRefObject<undefined>' is not assignable to type 'RefObject<HTMLDialogElement>'. Types of property 'current' are incompatible. Type 'undefined' is not assignable to type 'HTMLDialogElement | null'.ts(2322)
By default, React.useRef()
is initialized with undefined, leading TypeScript to infer the type as MutableRefObject<undefined>
. Since we're working with an HTML <dialog>
element, we should provide the correct type for the dialog ref: HTMLDialogElement | null
.
Here's a corrected version of your Modal component:
// Ensure the modal root exists
const modalRoot = document.getElementById("modal-root");
const Modal = forwardRef(function Modal(
{ children }: ModalProps,
ref: React.Ref<{ open: () => void }>
) {
// Define the ref with the correct type
const dialog = useRef<HTMLDialogElement | null>(null);
// Use imperative handle to expose functions to the parent component
useImperativeHandle(ref, () => ({
open() {
if (dialog.current) {
dialog.current.showModal();
}
},
}));
// Render the dialog inside the modal root using portals
if (modalRoot) {
return createPortal(
<dialog ref={dialog}>{children}</dialog>,
modalRoot
);
}
return null;
});
- Type for dialog Ref:
- Changed
const dialog = useRef();
toconst dialog = useRef<HTMLDialogElement | null>(null);
to specify that the dialog ref references an HTML<dialog>
element.
- Changed
- useImperativeHandle Type:
- Defined the type of
ref
asReact.Ref<{ open: () => void }>
to specify that the parent component can use the open function.
- Defined the type of
- Portal Check:
- Added a check for modalRoot to handle cases where modal-root is missing, ensuring a graceful fallback.
- createPortal Typing:
- Fixed dialog ref type mismatch by ensuring it matches the expected type
React.RefObject<HTMLDialogElement>
. This should eliminate the TypeScript errors and ensure proper type safety in your component.
- Fixed dialog ref type mismatch by ensuring it matches the expected type
-
Default Behavior of ref:
- Normally, a ref in React points directly to an element (
HTMLDialogElement
,HTMLDivElement
, etc.). However, when you useforwardRef
with imperative handles, you're essentially customizing what the parent can "see" through that ref.
- Normally, a ref in React points directly to an element (
-
Custom API for the Parent:
- Instead of exposing the raw DOM node (HTMLDialogElement), you're exposing an object with specific methods, like
{ open: () => void }
. TypeScript requires we to explicitly define the shape of that object.
- Instead of exposing the raw DOM node (HTMLDialogElement), you're exposing an object with specific methods, like
-
React's Ref Type:
The typeReact.Ref<T>
represents a ref that can either:- Be a callback ref (function).
- Be a
RefObject<T>
(created by useRef). - Or be null.
Since our component will expose the open() method, we declare the ref type as React.Ref<{ open: () => void }>
, letting TypeScript know exactly what the parent will receive.
fix the typing issue in our handleAddTask function.
// App.tsx
const handleAddTask = (text: string) => {
setStateProjects((prevStateProjects: InitState) => {
if (!prevStateProjects.selectedProjectId) {
return prevStateProjects; // Return unchanged if no project is selected
}
const newTask: TaskProps = {
id: prevStateProjects.selectedProjectId,
text,
taskId: Date.now()
};
return {
...prevStateProjects,
tasks: [newTask, ...prevStateProjects.tasks]
};
});
};
That was a first solution. Then I added ainterface TasksProps {tasks: Task[]}
Refactoring with Context API redefining types
-
Avoids Prop Drilling
- Context simplifies the process of passing data deeply nested in a component tree. This is especially useful for global states like themes, authentication, or language preferences.
-
Improved Type Safety
- TypeScript ensures that the context's shape is consistent across components. With well-defined types, developers are less prone to runtime errors caused by mismatched data structures.
-
Better Developer Experience
- Intellisense in IDEs (e.g., VSCode) leverages TypeScript types, making it easier to use context values correctly and reducing the learning curve for new developers.
-
Scalability for Small to Medium Apps
- Context API works well for apps with manageable state requirements, providing a simpler alternative to libraries like Redux for medium-sized projects.
Footnotes
-
This course can be found in Udemy website. ↩