TypeScript for React

To excel in React with TypeScript, it’s crucial to master TypeScript's core types and features like interfaces, types, unions, and enums. Here’s a rundown on when and how to use each, and some advanced tips for maximizing your TypeScript effectiveness in React.

Stage:-1 (Beginner)

1. Basic Types: string, number, boolean, etc.

When to Use: For simple variables or function parameters with straightforward data (like username: string or age: number), use these primitive types. They’re essential for adding clarity to function inputs and outputs.

Example:

let name: string = "Babajan";
let age: number = 25;

2. Type Aliases (type)

When to Use: Use type aliases when you want to create a new name for any type, even complex ones. They’re perfect for union types, complex objects, or function signatures. They’re also versatile because you can define unions, intersections, and mapped types with them.

Example:

type ID = string | number;
type User = {
   id: ID;
   name: string;
   isActive: boolean;
};

3. Interfaces

When to Use: Interfaces are especially powerful when defining the structure of objects. They’re extendable, so they’re ideal for situations where you might need to add more properties over time. Use interfaces for props, states, and defining component structures in React.

Key Note: Interfaces can only describe object types, whereas type aliases are more flexible.

Example:

interface User {
   id: number;
   name: string;
}

interface Employee extends User {
   salary: number;
}

4. Unions and Intersections

  • Union Types (|): Use unions to allow a value to be one of multiple types. Common in React when a prop can accept different forms of data.

    Example:

      type Status = "loading" | "success" | "error";
    
      function fetchData(status: Status) {
         // handle different status cases
      }
    
  • Intersection Types (&): Use intersections to combine multiple types into one. Great for merging multiple interfaces or extending component prop types.

    Example:

      interface Base {
         id: number;
      }
    
      interface Details {
         description: string;
      }
    
      type Item = Base & Details; // has both id and description
    

5. Enums

When to Use: Use enums when you need a clear set of options or states, especially when each option is distinct and doesn’t overlap. Enums can make state management more expressive, like differentiating button styles or page layouts.

Example:

enum Status {
   Loading,
   Success,
   Error,
}

function fetchData(status: Status) {
   if (status === Status.Loading) {
      // handle loading state
   }
}

6. Generics

When to Use: Generics make your code more reusable and flexible by allowing functions and components to work with any type. Use generics in React when building reusable components like lists, buttons, or form inputs where the types of data might change.

Example:

function useArray<T>(initialValue: T[]): [T[], (val: T) => void] {
   const [array, setArray] = useState(initialValue);
   const addToArray = (val: T) => setArray([...array, val]);
   return [array, addToArray];
}

7. Utility Types

TypeScript has utility types that make it easy to manipulate types. Some useful ones for React are:

  • Partial<Type>: Makes all properties in a type optional. Perfect for props that may or may not be fully initialized.

  • Pick<Type, Keys>: Constructs a type by picking a subset of properties from another type.

  • Omit<Type, Keys>: Constructs a type by omitting specific properties.

Example:

interface FullProps {
   id: number;
   name: string;
   age: number;
}

type PartialProps = Partial<FullProps>;
type PickedProps = Pick<FullProps, 'id' | 'name'>;

8. Type Guards

Type guards help narrow down types during runtime, making your code safer and more precise. Commonly used with union types, they’re valuable in functions with multiple possible argument types.

Example:

function isString(value: unknown): value is string {
   return typeof value === 'string';
}

function processValue(value: string | number) {
   if (isString(value)) {
      console.log(value.toUpperCase()); // Safe as TypeScript knows it's a string
   }
}

In Summary

  • Use basic types for simple data.

  • Use type aliases for unions or more flexible type combinations.

  • Use interfaces for objects, especially in React props and component structures.

  • Use unions for multiple potential types, and intersections to combine them.

  • Use enums for distinct states or options.

  • Use generics for reusable functions and components.

  • Use utility types to manipulate types efficiently.

Mastering these will streamline your code, reduce errors, and make you exceptionally efficient in React with TypeScript!

To become highly efficient in React with TypeScript, understanding more advanced TypeScript patterns like Higher-Order Components (HOC), render props, and custom hooks is essential. Here’s a deep dive into these patterns and techniques with TypeScript examples.

Stage 2:- Intermediate

1. Higher-Order Components (HOC)

HOCs are functions that take a component and return a new component with enhanced capabilities. They’re often used for adding reusable logic across multiple components.

Example: Creating an HOC to add loading state functionality.

import React, { ComponentType } from 'react';

interface WithLoadingProps {
   isLoading: boolean;
}

function withLoading<T>(WrappedComponent: ComponentType<T>) {
   return (props: T & WithLoadingProps) => {
      const { isLoading, ...restProps } = props;
      return isLoading ? <div>Loading...</div> : <WrappedComponent {...restProps as T} />;
   };
}

// Usage
interface UserProps {
   name: string;
}

const UserComponent: React.FC<UserProps> = ({ name }) => <div>{name}</div>;

const UserWithLoading = withLoading(UserComponent);

// Rendered as:
// <UserWithLoading isLoading={true} name="John Doe" />

Explanation:

  • The withLoading HOC takes a component and returns a new one that shows a loading state if isLoading is true.

  • T is a generic type, allowing withLoading to accept any component and preserve its props.

2. Render Props Pattern

Render props is a pattern for sharing code between React components using a prop whose value is a function.

Example: Creating a component to manage hover state.

import React, { useState, ReactNode } from 'react';

interface HoverProps {
   children: (isHovered: boolean) => ReactNode;
}

const Hover: React.FC<HoverProps> = ({ children }) => {
   const [isHovered, setHovered] = useState(false);

   return (
      <div
         onMouseEnter={() => setHovered(true)}
         onMouseLeave={() => setHovered(false)}
      >
         {children(isHovered)}
      </div>
   );
};

// Usage
const App = () => (
   <Hover>
      {(isHovered) => <div>{isHovered ? 'Hovered!' : 'Not Hovered'}</div>}
   </Hover>
);

Explanation:

  • Hover component provides isHovered state to its children via a function.

  • The children function can conditionally render based on isHovered.

3. Custom Hooks with TypeScript

Custom hooks are a great way to extract and reuse stateful logic across components. TypeScript enhances them by enforcing types on input parameters and return values.

Example: Creating a custom hook for form handling.

import { useState, ChangeEvent } from 'react';

function useForm<T>(initialState: T) {
   const [form, setForm] = useState<T>(initialState);

   const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
      const { name, value } = e.target;
      setForm((prev) => ({ ...prev, [name]: value }));
   };

   return { form, handleChange };
}

// Usage
interface FormState {
   username: string;
   email: string;
}

const App = () => {
   const { form, handleChange } = useForm<FormState>({ username: '', email: '' });

   return (
      <form>
         <input name="username" value={form.username} onChange={handleChange} />
         <input name="email" value={form.email} onChange={handleChange} />
      </form>
   );
};

Explanation:

  • useForm is a reusable hook for managing form inputs.

  • T is a generic type representing the shape of the form state, making useForm adaptable to any form structure.

4. Advanced Generic Components

Creating components with generics allows them to handle different data types while preserving type safety.

Example: Creating a flexible List component.

import React from 'react';

interface ListProps<T> {
   items: T[];
   renderItem: (item: T) => React.ReactNode;
}

function List<T>({ items, renderItem }: ListProps<T>) {
   return <ul>{items.map((item, index) => <li key={index}>{renderItem(item)}</li>)}</ul>;
}

// Usage
const App = () => {
   const numbers = [1, 2, 3];
   return (
      <List items={numbers} renderItem={(item) => <span>{item}</span>} />
   );
};

Explanation:

  • List is a generic component that takes an array of any type T.

  • renderItem is a function prop for rendering each item, making List highly customizable.

5. TypeScript and Context API

TypeScript is excellent for working with the Context API, especially when defining context values and consumers.

Example: Creating a context with typed value.

import React, { createContext, useContext, ReactNode } from 'react';

interface AuthContextType {
   isAuthenticated: boolean;
   login: () => void;
   logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
   const login = () => { /* login implementation */ };
   const logout = () => { /* logout implementation */ };

   return (
      <AuthContext.Provider value={{ isAuthenticated: false, login, logout }}>
         {children}
      </AuthContext.Provider>
   );
};

function useAuth() {
   const context = useContext(AuthContext);
   if (!context) {
      throw new Error("useAuth must be used within an AuthProvider");
   }
   return context;
}

// Usage in a component
const LoginButton = () => {
   const { login } = useAuth();
   return <button onClick={login}>Login</button>;
};

Explanation:

  • AuthContextType defines the shape of the context, including methods like login and logout.

  • AuthProvider component provides the context, while useAuth is a custom hook for consuming it safely.

6. Discriminated Unions for Complex States

Discriminated unions (using a shared literal property) can simplify managing complex state types, especially in reducers.

Example: Managing state with discriminated unions.

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string };
type ErrorState = { status: "error"; error: string };

type State = LoadingState | SuccessState | ErrorState;

const state: State = { status: "success", data: "Loaded successfully" };

if (state.status === "success") {
   console.log(state.data); // TypeScript knows "data" exists here
}

Explanation:

  • This approach ensures that each state variant (loading, success, error) has a unique status field, allowing TypeScript to narrow the type based on status.

Summary of Tips

  • Use HOCs for reusable logic but consider hooks as a simpler alternative for function components.

  • Render Props are useful but less common in modern React due to hooks.

  • Custom Hooks offer an elegant solution for sharing logic across components.

  • Generics and Utility Types allow you to create flexible, reusable components with type safety.

  • Discriminated Unions work well for managing complex component states and reducers.

These patterns are powerful tools in TypeScript for building scalable and maintainable React applications. Using them effectively will help you become a top-tier engineer, creating highly optimized and type-safe React applications.

Stage 3 :- Advanced

To further master TypeScript in React, let’s cover advanced techniques like type inference with conditional types, advanced utility types, discriminated unions for state management, and pattern matching with TypeScript. These tools will help you build highly flexible and reusable components and maintain strict type safety across complex codebases.

1. Conditional Types

Conditional types allow you to create types that depend on other types. They’re powerful for handling types that vary based on certain conditions, which is useful for reusable functions, components, and utility types.

Example: Defining a conditional type for handling a component’s props.

type Status = 'success' | 'error';

// A conditional type to check if a status is 'success'
type Message<T extends Status> = T extends 'success' ? { data: string } : { error: string };

// Usage
const successMessage: Message<'success'> = { data: 'Operation was successful' };
const errorMessage: Message<'error'> = { error: 'An error occurred' };

Explanation:

  • Message conditionally defines data for 'success' status and error for 'error'.

  • This is useful when building components or hooks that may need to return or handle different types of responses.

2. Mapped Types

Mapped types allow you to transform all properties in a type or make a subset of properties optional or readonly. This is especially useful for modifying the structure of props or state types.

Example: Making all properties of an object type optional.

type Optional<T> = {
   [P in keyof T]?: T[P];
};

interface User {
   id: number;
   name: string;
   email: string;
}

const partialUser: Optional<User> = { id: 1 }; // name and email are optional now

Explanation:

  • Optional is a generic mapped type that makes all properties of T optional. This approach can be helpful when you want to handle partial data or optional props.

3. Advanced Utility Types

TypeScript’s built-in utility types can handle complex type transformations. Here are a few powerful ones:

  • ReturnType<T>: Extracts the return type of a function.

  • Parameters<T>: Extracts the parameter types of a function as a tuple.

  • InstanceType<T>: Gets the instance type of a class.

  • NonNullable<T>: Removes null and undefined from a type.

Example: Extracting parameter and return types.

const add = (a: number, b: number): number => a + b;

type AddParams = Parameters<typeof add>; // [number, number]
type AddReturnType = ReturnType<typeof add>; // number

Explanation:

  • Parameters is used to extract the parameter types as a tuple, while ReturnType gives the return type. This is useful for function-based components or hooks that might be reused in different contexts.

4. Discriminated Unions for Complex State

In complex applications, discriminated unions can simplify state management by allowing type-safe checking and access based on a common property (discriminator). They work well with reducers or component states that have multiple possible types.

Example: Defining state types for a data fetch with a discriminator.

type FetchState =
   | { status: 'loading' }
   | { status: 'success'; data: string }
   | { status: 'error'; error: string };

function fetchData() {
   let state: FetchState = { status: 'loading' };

   // Simulate a fetch
   state = { status: 'success', data: 'Data loaded successfully' };

   if (state.status === 'success') {
      console.log(state.data); // TypeScript knows 'data' is available here
   }
}

Explanation:

  • Each state (loading, success, error) has a unique status, allowing TypeScript to narrow down which properties exist based on status.

5. Pattern Matching with TypeScript’s Exhaustive Checks

Forcing exhaustive checks on discriminated unions in switch statements ensures all cases are handled, which is especially helpful in reducers.

Example: Using exhaustive checks in a reducer.

type FetchAction =
   | { type: 'FETCH_START' }
   | { type: 'FETCH_SUCCESS'; payload: string }
   | { type: 'FETCH_ERROR'; error: string };

const fetchReducer = (state: FetchState, action: FetchAction): FetchState => {
   switch (action.type) {
      case 'FETCH_START':
         return { status: 'loading' };
      case 'FETCH_SUCCESS':
         return { status: 'success', data: action.payload };
      case 'FETCH_ERROR':
         return { status: 'error', error: action.error };
      default:
         const _exhaustiveCheck: never = action;
         return _exhaustiveCheck;
   }
};

Explanation:

  • _exhaustiveCheck ensures that if a new action type is added, TypeScript will raise an error if it’s not handled in the switch.

6. Template Literal Types

Template literal types allow you to build types using string manipulation. They’re useful for enforcing consistent naming or generating new types.

Example: Enforcing consistent naming conventions for events.

type EventName = 'onClick' | 'onHover';
type PrefixedEvent<T extends string> = `app-${T}`;

type AppEvents = PrefixedEvent<EventName>; // "app-onClick" | "app-onHover"

Explanation:

  • PrefixedEvent generates event types with a prefix, which can help standardize event naming across your application.

7. React Component Props Inference with Generics

When building wrapper components, it’s common to infer the props of the wrapped component so the wrapper can pass them through seamlessly.

Example: Wrapping a component and inferring its props.

import React, { ComponentType } from 'react';

function withLogging<T>(WrappedComponent: ComponentType<T>) {
   return (props: T) => {
      console.log('Rendering component with props:', props);
      return <WrappedComponent {...props} />;
   };
}

// Usage
interface ButtonProps {
   label: string;
}

const Button: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>;

const ButtonWithLogging = withLogging(Button);

Explanation:

  • withLogging uses a generic T to capture the props of WrappedComponent so it can apply those props correctly.

8. Asserting Component Prop Types Using React.ComponentProps

React.ComponentProps extracts the props of a given component. This is useful when you want to reuse a component's props in another type.

Example: Reusing props from a component.

const Input = (props: { value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }) => (
   <input {...props} />
);

type InputProps = React.ComponentProps<typeof Input>;

const InputWrapper: React.FC<InputProps> = (props) => <Input {...props} />;

Explanation:

  • React.ComponentProps<typeof Input> infers the Input component’s props, making them reusable without manually redefining the type.

Advanced TypeScript Tips

  • Combine Conditional Types with Utility Types: This can create complex types for dynamic validation, like removing optional keys based on certain conditions.

  • Leverage Generics for Custom Hooks and Context Providers: Custom hooks and contexts often benefit from generics, allowing flexible yet strongly typed access to state and behavior.

  • Discriminated Union with Exhaustive Matching in Reducers: Type-safe reducers prevent runtime errors and simplify state transitions.

  • Template Literal Types for Custom Namespaces: Use them for building consistent naming schemes across events, actions, or identifiers.

These advanced TypeScript techniques will help you write clean, scalable, and maintainable code, and significantly improve your efficiency as a React engineer!