TypeScript in Next.js: A Comprehensive Guide for Beginners
This serves as a guide for Next.js developers interested in incorporating TypeScript into their projects. We'll explore key concepts, installation steps, and code examples to illustrate their practical application.
What is TypeScript?
TypeScript is a superset of JavaScript that adds optional static typing. This means you can define data types for variables, functions, and other constructs, improving code clarity, maintainability, and catching errors early.
Benefits of TypeScript in Next.js:
- Catch errors early: Identify potential issues during development, preventing runtime bugs.
- Improved code navigation: IDEs leverage types for better autocompletion, refactoring, and code understanding.
- Larger projects, better structure: Enforces SOLID principles and promotes maintainability in complex applications.
- Better documentation: Types serve as documentation directly within code, aiding team collaboration.
Why use TypeScript in Next.js in 2024?
- Growing popularity: TypeScript adoption is skyrocketing across JavaScript frameworks, including Next.js.
- Enhanced Next.js development: Next.js offers built-in support and type definitions for its features.
- Future-proof development: Static typing aligns with modern development trends and best practices.
Installing TypeScript in a Next.js Project
- Next.js provides a TypeScript-first development experience for building your React application.
- It comes with built-in TypeScript support for automatically installing the necessary packages and configuring the proper settings.
Next.js now ships with TypeScript, ESLint, and Tailwind CSS configuration by default. - You can optionally use a src directory in the root of your project to separate your application's code from configuration files.
I recommend starting a new Next.js app using create-next-app
, which sets up everything automatically for you. To create a project, run:
npx create-next-app@latest
On installation, you'll see the following prompts:
What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
TypeScript Inference Explained
TypeScript, as a superset of JavaScript, offers optional static typing. This means it can automatically deduce the type of variables, functions, and other constructs based on their usage, a process known as TypeScript inference. While you can explicitly define types through annotations, inference helps make your code more concise and readable.
Here's a breakdown of key points about TypeScript inference:
What can be inferred?
- Variable initialization: When assigning a value to a variable, TypeScript infers its type based on the value's type, e.g.,
let age = 30; // age is inferred as number.
- Default parameter values: The type of a function parameter with a default value is inferred based on the value's type.
- Function return types: If a function doesn't have an explicit return type annotation, TypeScript attempts to infer it based on the return statements within the function.
- Arrays and objects: The inferred type of arrays and objects reflects the types of their elements and properties.
Example:
let name = "John"; // Inferred type: string
const age = 30; // Inferred type: number
function double(num: number): number {
// Inferred return type: number
return num * 2;
}
const numbers = [1, 2, 3]; // Inferred type: number[]
const person = { name: "Jane", age: 25 }; // Inferred type: { name: string, age: number }
Benefits of inference:
- Reduced boilerplate: Saves time and effort by avoiding unnecessary type annotations.
- Improved readability: Cleaner code, focusing on logic rather than verbose type details.
- Automatic error detection: Catches type mismatches early, improving code quality.
Limitations of inference:
- Not always perfect: In complex scenarios, TypeScript might not infer the correct type, requiring manual annotations.
- Reduced flexibility: Over-reliance on inference can limit code flexibility and maintainability.
Best practices:
- Use inference effectively while understanding its limitations.
- Annotate types in complex scenarios for clarity and accuracy.
- Balance conciseness with readability and maintainability.
Typescript Basic Types
Boolean
- Represents truth values: true or false. -Used for conditional checks, flags, and logical operations.
let isDone: boolean = false;
let isLoggedIn: boolean = true;
let showError: boolean = false;
Numbers
Represents numeric values, including integers and floating-point numbers. Used for calculations, measurements, and numerical operations.
let age: number = 30;
let temperature: number = 25.5;
Strings
- Represents sequences of text characters.
- Used for user input, display text, and string manipulation.
let name: string = "John Doe";
let message: string = "Hello, world!";
Arrays:
- Represent an ordered collection of elements of the same or compatible types. -Defined using square brackets [] with the element type inside.
// Array of numbers
let numbers: number[] = [1, 2, 3];
// Error: Type 'string' is not assignable to type 'number'
numbers.push("hello"); // Compiler error
// Array of strings
let names: string[] = ["Alice", "Bob", "Charlie"];
Tuples
- Fixed-length arrays with specific element types at each position.
- Type annotation: [type1, type2, ...]
// Correct usage
let person: [string, number] = ["John", 30];
// Error: Type 'number' is not assignable to type 'string'
person = [25, "Doe"]; // Compiler error
Functions
- Defined using the function keyword, specifying input and output types.
- Type annotation: (parameters: types) => returnType
function add(x: number, y: number): number {
return x + y;
}
// Error: Argument of type 'string' is not assignable to parameter of type 'number'
add("5", 10); // Compiler error
Void
- Represents the absence of a value, often used for functions that don't return anything.
- Type annotation: void
function logMessage(message: string): void {
console.log(message);
}
Enum
- An enum is a special "class" that represents a group of constants (unchangeable variables).
- Enums come in two flavors string and numeric. Lets start with numeric. Numeric Enums - Default By default, enums will initialize the first value to 0 and add 1 to each additional value:
enum CardinalDirections {
North,
East,
South,
West,
}
let currentDirection = CardinalDirections.North;
// logs 0
console.log(currentDirection);
// throws error as 'North' is not a valid enum
currentDirection = "North"; // Error: "North" is not assignable to type 'CardinalDirections'.
Numeric Enums - Initialized You can set the value of the first numeric enum and have it auto increment from that:
enum CardinalDirections {
North = 1,
East,
South,
West,
}
// logs 1
console.log(CardinalDirections.North);
// logs 4
console.log(CardinalDirections.West);
Numeric Enums - Fully Initialized You can assign unique number values for each enum value. Then the values will not incremented automatically:
enum StatusCodes {
NotFound = 404,
Success = 200,
Accepted = 202,
BadRequest = 400,
}
// logs 404
console.log(StatusCodes.NotFound);
// logs 200
console.log(StatusCodes.Success);
String Enums Enums can also contain strings. This is more common than numeric enums, because of their readability and intent.
enum CardinalDirections {
North = "North",
East = "East",
South = "South",
West = "West",
}
// logs "North"
console.log(CardinalDirections.North);
// logs "West"
console.log(CardinalDirections.West);
Object Type:
- Represents a collection of key-value pairs, where keys are strings and values can be of any type.
- Used to model complex data structures and real-world entities.
const car: { type: string; model: string; year: number } = {
type: "Toyota",
model: "Corolla",
year: 2009,
};
Type Inference TypeScript can infer the types of properties based on their values.
const car = {
type: "Toyota",
};
car.type = "Ford"; // no error
car.type = 2; // Error: Type 'number' is not assignable to type 'string'.
Optional Properties Optional properties are properties that don't have to be defined in the object definition. Example without optional properties
const car: { type: string; mileage: number } = {
// Error: Property 'mileage' is missing in type '{ type: string; }' but required in type '{ type: string; mileage: number; }'.
type: "Toyota",
};
car.mileage = 2000;
Example with Optional properties
const car: { type: string; mileage?: number } = {
// no error
type: "Toyota",
};
car.mileage = 2000;
The 'Any' Type
- TypeScript defaults to the any type if no type is specified, allowing any value. This basically means writing in normal javascript
- The any type in TypeScript is a workaround for cases when you don't know what type a value might be. It allows you to assign a variable or function parameter to be of literally any type.
- This can lead to runtime errors and reduced type safety.
function greet(name: any) {
console.log(`Hello, ${name}!`);
}
greet("John"); // Outputs: Hello, John!
greet(123); // Outputs: Hello, 123!
any lets you do extremely dangerous things in TypeScript. By marking a variable as any
const user: any = {
name: "John",
age: 30,
};
user.roles.push("admin"); // Runtime error!
In the example above, we're trying to push a new value to the roles array, but the roles property doesn't exist on the user object. This will cause a runtime error, but TypeScript won't warn us about it because we've marked the user variable as any.
Any VS Unknown
The unknown type is a type-safe counterpart of any, which requires you to perform a type check before using the variable. Unlike any, unknown can help prevent runtime errors by forcing you to narrow down the type of the variable before using it in your code.
Here's an example of using unknown in a function argument:
function greet(name: unknown) {
if (typeof name === "string") {
console.log(`Hello, ${name}!`);
}
}
greet("John"); // Outputs: Hello, John!
greet(123); // No output
Overall, the any type can be useful in situations where you need more flexibility, but it comes with the cost of losing type safety. It's best to use it sparingly and with caution.
TypeScript Union Types
- Union types are used when a value can be more than a single type.
- Allow a variable to hold values of different types.
- Type annotation: type1 | type2 | ...
- Such as when a property would be string or number. Union | (OR)
- Using the | we are saying our parameter is a string or number:
let value: string | number = "hello";
// Error: Object is of type 'object', not assignable to 'string | number'
value = {}; // Compiler error
function printStatusCode(code: string | number) {
console.log(`My status code is ${code}.`);
}
printStatusCode(404);
printStatusCode("404");
Type aliases
A type alias is a name for any type, you can actually use a type alias to give a name to any type at all, not just an object type.
// primitive
type SanitizedString = string;
// Type alias for a string with maximum length of 10
type Email = string & { length: 10 };
// Union type for numbers or strings
type ID = number | string;
// tuple
type Data = [number, string];
// function
type SetPoint = (x: number, y: number) => void;
// object
type User = {
id: number;
name: string;
isVerified?: boolean;
handler: () => void;
};
Interfaces
- Interfaces are similar to type aliases, except they only apply to object types.
- An interface may only be used to declare the shapes of objects, not rename primitives. Being concerned only with the structure and capabilities of types is why we call TypeScript a structurally typed type system.
interface Rectangle {
height: number;
width: number;
}
const rectangle: Rectangle = {
height: 20,
width: 10,
};
Extending Interfaces
- Interfaces can extend each other's definition.
- Extending an interface means you are creating a new interface with the same properties as the original, plus something new.
interface Rectangle {
height: number;
width: number;
}
interface ColoredRectangle extends Rectangle {
color: string;
}
const coloredRectangle: ColoredRectangle = {
height: 20,
width: 10,
color: "red",
};
Type vs Interface: Which Should You Use?
Differences Between Type Aliases and Interfaces Type aliases and interfaces are very similar, and in many cases you can choose between them freely. Almost all features of an interface are available in type, the key distinction is that a type cannot be re-opened to add new properties vs an interface which is always extendable. Extending an interface
interface Animal {
name: string;
}
interface Bear extends Animal {
honey: boolean;
}
const bear = getBear();
bear.name;
bear.honey;
Extending a type via intersections
type Animal = {
name: string;
};
type Bear = Animal & {
honey: boolean;
};
const bear = getBear();
bear.name;
bear.honey;
For the most part, you can choose based on personal preference, and TypeScript will tell you if it needs something to be the other kind of declaration. Use Interface until You Need Type. (From typescript team)
Here's a helpful rule of thumb:
-
Always use interface for public API's definition when authoring a library or 3rd party ambient type definitions, as this allows a consumer to extend them via declaration merging if some definitions are missing.
-
Consider using type for your React Component Props and State, for consistency and because it is more constrained.
-
Interfaces can't express unions, mapped types, or conditional types. Type aliases can express any type.
-
Interfaces can use extends, types can't.
-
When you're working with objects that inherit from each other, use interfaces. extends makes TypeScript's type checker run slightly faster than using &.
TypeScript Casting
- There are times when working with types where it's necessary to override the type of a variable, such as when incorrect types are provided by a library.
- Casting is the process of overriding a type.
Casting with as A straightforward way to cast a variable is using the as keyword, which will directly change the type of the given variable.
let x: unknown = "hello";
console.log((x as string).length);
Casting doesn't actually change the type of the data within the variable, for example the following code will not work as expected since the variable x is still holds a number.
let x: unknown = 4;
console.log((x as string).length); // prints undefined since numbers don't have a length
TypeScript will still attempt to typecheck casts to prevent casts that don't seem correct, for example the following will throw a type error since TypeScript knows casting a string to a number doesn't makes sense without converting the data:
console.log((4 as string).length); // Error: Conversion of type 'number' to type 'string' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
TypeScript Type Guards
Type guards are special checks you can perform on a variable to narrow down its possible types within a specific block of code. Think of them like filters that refine your understanding of a variable's type based on some condition. This allows you to access properties and methods that wouldn't be available on the wider, less specific type.
Built-in type guards:
typeof operator: Checks the type of a variable against basic types like string, number, etc. (typeof x === "string")
.
instanceof operator: Checks if an object belongs to a specific class (object instanceof MyClass)
.
in operator: Checks if a property exists on an object ("name" in object)
.
type alphanumeric = string | number;
function add(a: alphanumeric, b: alphanumeric) {
if (typeof a === "number" && typeof b === "number") {
return a + b;
}
if (typeof a === "string" && typeof b === "string") {
return a.concat(b);
}
throw new Error(
"Invalid arguments. Both arguments must be either numbers or strings."
);
}
TypeScript Generics – Use Case and Examples
To understand What TypeScript Generics are lets look at the Use cases Use Case for Generics Let's start with a simple example, where you want to print the value of an argument passed:
function printData(data: number) {
console.log("data: ", data);
}
printData(2);
Now, let's suppose you want to make printData a more generic function, where you can pass any type of argument to it like: number/ string/ boolean. So, you might think to follow an approach like below:
function printData(data: number | string | boolean) {
console.log("data: ", data);
}
printData(2);
printData("hello");
printData(true);
But in the future, you might want to print an array of numbers using the same function. In that case the types will increase and it will become cumbersome to maintain all those different types.
This is when Generics come into the picture.
How Generics Work in TS
Generics are like variables – to be precise, type variables – that store the type (for example number, string, boolean) as a value.
So, you can solve the problem we discussed above with generics as shown below:
function printData<T>(data: T) {
console.log("data: ", data);
}
printData(2);
printData("hello");
printData(true);
In the above example printData-generics.ts, there is a slight difference in syntax:
You use a type variable inside angular brackets after the function name <T>
You then assign the type variable to the parameter data: T
Let's explore these differences a bit more.
To use generics, you need to use angular brackets and then specify a type variable inside them. Developers generally use T, X and Y. But it can be anything depending upon your preference.
You can then assign the same variable name as the type to the parameter of the function.
Now, whatever argument you pass to the function, it gets inferred and there's no need to hardcode the type anywhere.
Even if you pass an array of numbers or an object to the printData function, everything will be displayed properly without TS complaining:
function printData<T>(data: T) {
console.log("data: ", data);
}
printData(2);
printData("hello");
printData(true);
printData([1, 2, 3, 4, 5, 6]);
printData([1, 2, 3, "hi"]);
printData({ name: "Ram", rollNo: 1 });
Let's see another example:
function printData<X, Y>(data1: X, data2: Y) {
console.log("Output is: ", data1, data2);
}
printData("Hello", "World");
printData(123, ["Hi", 123]);
In above example, we passed 2 arguments to printData and used X and Y to denote the types for both the parameters. X refers to 1st value of the argument and Y refers to 2nd value of the argument.
Here as well, the types of data1 and data2 are not specified explicitly because TypeScript handles the type inference with the help of generics.
How to Use Generics with Interfaces
You can even use generics with interfaces. Let's see how that works with the help of a code snippet:
interface UserData<X, Y> {
name: X;
rollNo: Y;
}
const user: UserData<string, number> = {
name: "Ram",
rollNo: 1,
};
In above snippet, <string, number>
are passed to the interface UserData. In this way, UserData becomes a reusable interface in which any data type can be assigned depending upon the use case.
Here in this example, name and rollNo will always be string and number, respectively. But this example was to showcase how you can use generics with interfaces in TS.
Typescript in Next JS and React
TypeScript with React Components props
Every file containing JSX must use the .tsx file extension. This is a TypeScript-specific extension that tells TypeScript that this file contains JSX.
Writing TypeScript with React is very similar to writing JavaScript with React. The key difference when working with a component is that you can provide types for your component’s props. These types can be used for correctness checking and providing inline documentation in editors.
function MyButton({ title }: { title: string }) {
return <button>{title}</button>;
}
export default function MyApp() {
return (
<div>
<h1>Welcome to my app</h1>
<MyButton title="I'm a button" />
</div>
);
}
type Product = {
title: string;
price: number;
rating: number;
};
interface IProduct {
title: string;
price: number;
rating: number;
}
export default function Product({ title, price, rating }: Product) {
return (
<div>
<h2>{title}</h2>
<p>{price}</p>
<p>{rating}</p>
</div>
);
}
** Components with Children Props**
import React, { ReactNode } from 'react';
type FormCardWrapperProps = {
children: ReactNode;
};
const FormCardWrapper({ children }:FormCardWrapperProps) => {
return (
<div style={{ border: '1px solid #ccc', padding: '20px', borderRadius: '5px', boxShadow: '0 2px 5px rgba(0,0,0,0.1)' }}>
{children}
</div>
);
};
export default FormCardWrapper;
import React from 'react';
import FormCardWrapper from './FormCardWrapper';
const MyForm: React.FC = () => {
return (
<FormCardWrapper>
<form>
{/* Form inputs go here */}
<input type="text" placeholder="Name" />
<input type="email" placeholder="Email" />
<button type="submit">Submit</button>
</form>
</FormCardWrapper>
);
};
export default MyForm;
Alternative ways to Annotate props types
import { FC } from "react";
type Shape = {
name: string;
age: number;
};
const User: FC<Shape> = ({ name, age }) => {
return (
<div>
<h2>{name}</h2>
<p>{age}</p>
</div>
);
};
export default User;
Types for Dynamic Routes
For example, a blog could include the following route app/blog/[slug]/page.js where [slug]
is the Dynamic Segment for blog posts.
export default function Page({
params: { slug },
}: {
params: { slug: string };
}) {
return <div>My Post: {slug}</div>;
}
///Alternatively
type Shape = {
params: { id: number };
};
export default function User({ params: { id } }: Shape) {
return <div>User id: {id}</div>;
}
Re-Usable Types
Using types to reuse types
type User = {
id: string;
username: string;
email: string;
password: string;
};
type Admin = User & {
isAdmin: boolean;
};
type ServiceProvider = User & {
serviceType: string;
};
Using Interfaces to reuse types
type User = {
id: string;
username: string;
email: string;
password: string;
};
interface Admin extends User {
isAdmin: boolean;
}
interface ServiceProvider extends User {
serviceType: string;
}
React Hooks
The type definitions from @types/react
include types for the built-in Hooks, so you can use them in your components without any additional setup. They are built to take into account the code you write in your component, so you will get inferred types a lot of the time and ideally do not need to handle the minutiae of providing the types.
However, we can look at a few examples of how to provide types for Hooks.
useState
The useState Hook will re-use the value passed in as the initial state to determine what the type of the value should be. For example:
// Infer the type as "boolean"
const [enabled, setEnabled] = useState(false);
Will assign the type of boolean to enabled, and setEnabled will be a function accepting either a boolean argument, or a function that returns a boolean. If you want to explicitly provide a type for the state, you can do so by providing a type argument to the useState call:
// Explicitly set the type to "boolean"
const [enabled, setEnabled] = useState<boolean>(false);
This is not very useful in this case, but a common case where you may want to provide a type is when you have a union type. For example, status here can be one of a few different strings:
type Status = "idle" | "loading" | "success" | "error";
const [status, setStatus] = useState<Status>("idle");
Also in many cases hooks are initialized with null-ish default values, and you may wonder how to provide types. Explicitly declare the type, and use a union type:
const [user, setUser] = useState<User | null>(null);
// later...
setUser(newUser);
Refs and Events Types
import React, { useRef, useState } from "react";
// Define User type
type User = {
name: string;
email: string;
password: string;
};
const FormExample = () => {
// State to store submitted data
const [submittedData, setSubmittedData] = useState<User | null>(null);
// Refs for form fields
const nameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
// Function to handle form submission
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); // Prevent default form submission behavior
// Get form field values using refs
const name = nameRef.current!.value;
// const name = nameRef.current?.value || "";
const email = emailRef.current?.value || "";
const password = passwordRef.current?.value || "";
// Create user object
const newUser: User = { name, email, password };
// Push user object into an array (For demonstration, you might use state or other state management tools)
const users: User[] = [];
users.push(newUser);
// Update submitted data state
setSubmittedData(newUser);
};
return (
<div>
<h2>Registration Form</h2>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
<input type="text" id="name" ref={nameRef} required />
</div>
<div>
<label htmlFor="email">Email:</label>
<input type="email" id="email" ref={emailRef} required />
</div>
<div>
<label htmlFor="password">Password:</label>
<input type="password" id="password" ref={passwordRef} required />
</div>
<button type="submit">Submit</button>
</form>
{/* Display submitted data */}
{submittedData && (
<div>
<h2>Submitted Data:</h2>
<p>Name: {submittedData.name}</p>
<p>Email: {submittedData.email}</p>
<p>Password: {submittedData.password}</p>
</div>
)}
</div>
);
};
export default FormExample;
Element Wrapper
import React from "react";
type ElementWrapperProps = {
elementType: "div" | "h1" | "button" | "section";
children: React.ReactNode;
className?: string;
onClick?: () => void;
};
const ElementWrapper: React.FC<ElementWrapperProps> = ({
elementType,
children,
className,
onClick,
}) => {
// Type assertion to ensure elementType is a valid JSX intrinsic element
const Element = elementType as keyof JSX.IntrinsicElements;
return (
<Element className={className} onClick={onClick}>
{children}
</Element>
);
};
export default ElementWrapper;
In this component:
ElementWrapper is a functional component that accepts props of type ElementWrapperProps.
elementType prop accepts a union type ('div' | 'h1' | 'button' | 'section')
representing valid HTML elements.
children prop represents the content inside the element.
className prop is optional and represents the CSS class for styling.
onClick prop is optional and represents the click event handler.
Inside the component, Element is declared using type assertion (as keyof JSX.IntrinsicElements)
to ensure elementType is a valid JSX intrinsic element.
We use Element to render the appropriate JSX element with provided props.
Here's how you can use the ElementWrapper component:
// import React from "react";
// import ElementWrapper from "./ElementWrapper";
// const App = () => {
// return (
// <div>
// {/* Example usage of ElementWrapper */}
// <ElementWrapper
// elementType="button"
// onClick={() => console.log("Button clicked")}
// >
// Click me
// </ElementWrapper>
// <ElementWrapper elementType="h1" className="heading">
// Hello, World!
// </ElementWrapper>
// <ElementWrapper elementType="div" className="container">
// <p>This is a paragraph inside a div.</p>
// </ElementWrapper>
// <ElementWrapper elementType="section">
// <h2>Section Title</h2>
// <p>This is a paragraph inside a section.</p>
// </ElementWrapper>
// </div>
// );
// };
// export default App;
Creating a Custome Button Element
import { ReactNode, FC } from "react";
type ButtonProps = {
variant?: "default" | "primary" | "secondary" | "linkButton";
href?: string; // Only applicable for linkButton variant
icon?: ReactNode;
onClick?: () => void;
className?: string; // Optional additional class name
};
const Button: FC<ButtonProps> = ({
variant = "default",
href,
icon,
onClick,
className = "",
children,
}) => {
let baseClassName =
"inline-flex items-center justify-center border font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2";
let styleClassName = "";
switch (variant) {
case "primary":
styleClassName =
"bg-blue-600 text-white border-transparent hover:bg-blue-700";
break;
case "secondary":
styleClassName =
"bg-gray-300 text-gray-700 border-gray-300 hover:bg-gray-400";
break;
case "linkButton":
styleClassName = "text-blue-600 border-transparent hover:text-blue-700";
break;
default:
styleClassName =
"bg-white text-gray-700 border-gray-300 hover:bg-gray-100";
}
const combinedClassName = `${baseClassName} ${styleClassName} ${className}`;
return (
<>
{href ? (
<a href={href} className={combinedClassName} onClick={onClick}>
{icon && <span className="mr-2">{icon}</span>}
{children}
</a>
) : (
<button type="button" className={combinedClassName} onClick={onClick}>
{icon && <span className="mr-2">{icon}</span>}
{children}
</button>
)}
</>
);
};
export default Button;
In this component:
We define a Button component that accepts props such as variant, href, icon, and onClick.
The variant prop is optional and can be one of 'default', 'primary', 'secondary', or 'linkButton'.
Based on the variant provided, we add Tailwind CSS classes to style the button accordingly.
If href is provided (applicable only for the 'linkButton' variant)
, we render an <a>
tag; otherwise, we render a <button>
tag.
The button can have an optional icon which will be rendered before the button text.
The onClick prop allows passing a click event handler.
You can use this Button component as follows:
import React from "react";
import Button from "./Button";
import { HiOutlineMail } from "react-icons/hi"; // Example icon from react-icons library
const App: React.FC = () => {
return (
<div>
<Button>Default Button</Button>
<Button variant="primary">Primary Button</Button>
<Button variant="secondary">Secondary Button</Button>
<Button variant="linkButton" href="#">
Link Button
</Button>
<Button variant="linkButton" href="#" icon={<HiOutlineMail />}>
Link Button with Icon
</Button>
<Button variant="primary" className="mr-2">
Primary Button with Custom Class
</Button>
</div>
);
};
export default App;
How to Use Zod and React Hook form
Form validation is a fundamental aspect of building robust web applications. It ensures that the data submitted by users conforms to the expected format and business rules.
Zod
Zod is a TypeScript-first schema declaration and validation library. It simplifies type definition and data validation in TypeScript by allowing developers to declare validators once, with automatic type deduction. Visit the the Documenntation here (opens in a new tab)
React Hook Form
React Hook Form is a lightweight, high-performance library for managing forms in React applications. It uses React hooks to simplify form logic, providing a fluid and efficient development experience.
It is a very complex task to handle validation. But with the help of React-hook-form and Zod, we can validate form in any real-world application so easily. Here are the steps:
Install packages
npm install react-hook-form @hookform/resolvers zod
create a schema using Zod
//schemas/schema.ts
import { z } from "zod";
export const signinSchema = z.object({
email: z
.string({
required_error: "Email is required",
})
.email({
message: "Not a valid email",
}),
password: z
.string({
required_error: "Password is required",
})
.min(6, {
message: "Password too short - should be 6 chars minimum",
}),
});
add useForm hook
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm({
resolver: zodResolver(signinSchema),
});
Give every input a different name
<input {...register("name")} />
Create a function that will be called on form submit
const handleSignin = () => {
//write all the logics
};
<form onSubmit={handleSubmit(handleSignin)}></form>;
handling errors
{
errors.email?.message && (
<p className="text-xs font-semibold text-red-700">
{" "}
*{errors.email?.message}{" "}
</p>
);
}
Some other Common Validations
Its a good practice to create a schema folder in the root
// src/schemas/schema.ts
import { z } from "zod";
export const formSchema = z.object({});
First and Last Name — We can use z.string()
do define strings and chain a .min()
check to apply a minimum length and add a custom error message. You can trim trailing whitespaces with .trim().
export const formSchema = z.object({
// ...
firstName: z
.string()
.trim()
.min(2, { message: "First name must be more than 1 character" }),
lastName: z
.string()
.trim()
.min(2, { message: "Last name must be more than 1 character" }),
// ...
});
Email and Website — Zod has some useful built-in validators for common string patterns.
export const formSchema = z.object({
// ...
email: z.string().trim().email(),
website: z.string().trim().url(),
// ...
});
Password — Again, another string refinement but with an added test to check if Password and Confirm Password are the same. To compare two fields to one another, we need to run the refinement on the entire schema object. In this case, if the passwords do not match, it might be nice to show the non-matching error as an error that belongs to the Confirm Password field. We can use .superRefine()
to gain access to the schema context and define highly customized errors.
export const formSchema = z
.object({
// ...
password: z
.string()
.refine(
(val) =>
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/.test(
val
),
{
message:
"Password must be at least 8 characters long and contain at least one uppercase character, one lowercase character, and one special symbol",
}
),
confirmPassword: z.string(),
// ...
})
.superRefine((val, ctx) => {
if (val.password !== val.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
Visit the the Documenntation here (opens in a new tab)
Context API Coming Soon
useReducer
You can use Discriminated Unions for reducer actions. Don't forget to define the return type of reducer, otherwise TypeScript will infer it.
import { useReducer } from "react";
const initialState = { count: 0 };
type ACTIONTYPE =
| { type: "increment"; payload: number }
| { type: "decrement"; payload: string };
function reducer(state: typeof initialState, action: ACTIONTYPE) {
switch (action.type) {
case "increment":
return { count: state.count + action.payload };
case "decrement":
return { count: state.count - Number(action.payload) };
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: "decrement", payload: "5" })}>
-
</button>
<button onClick={() => dispatch({ type: "increment", payload: 5 })}>
+
</button>
</>
);
}
useRef
In TypeScript, useRef returns a reference that is either read-only or mutable, depends on whether your type argument fully covers the initial value or not. Choose one that suits your use case. Option 1: DOM element ref To access a DOM element: provide only the element type as argument, and use null as initial value. In this case, the returned reference will have a read-only .current that is managed by React. TypeScript expects you to give this ref to an element's ref prop:
function Foo() {
// - If possible, prefer as specific as possible. For example, HTMLDivElement
// is better than HTMLElement and way better than Element.
// - Technical-wise, this returns RefObject<HTMLDivElement>
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Note that ref.current may be null. This is expected, because you may
// conditionally render the ref-ed element, or you may forget to assign it
if (!divRef.current) throw Error("divRef is not assigned");
// Now divRef.current is sure to be HTMLDivElement
doSomethingWith(divRef.current);
});
// Give the ref to an element so React can manage it for you
return <div ref={divRef}>etc</div>;
}
If you are sure that divRef.current will never be null, it is also possible to use the non-null assertion operator !:
const divRef = useRef<HTMLDivElement>(null!);
// Later... No need to check if it is null
doSomethingWith(divRef.current);
Useful React Prop Type Examples
Relevant for components that accept other React components as props.
export declare interface AppProps {
children?: React.ReactNode; // best, accepts everything React can render
childrenElement: React.JSX.Element; // A single React element
style?: React.CSSProperties; // to pass through style props
onChange?: React.FormEventHandler<HTMLInputElement>; // form events! the generic parameter is the type of event.target
// more info: https://react-typescript-cheatsheet.netlify.app/docs/advanced/patterns_by_usecase/#wrappingmirroring
props: Props & React.ComponentPropsWithoutRef<"button">; // to impersonate all the props of a button element and explicitly not forwarding its ref
props2: Props & React.ComponentPropsWithRef<MyButtonWithForwardRef>; // to impersonate all the props of MyButtonForwardedRef and explicitly forwarding its ref
}