Introduction to TypeScript in React: A Beginner's Guide

by Liben Hailu
13 mins read

Dive into TypeScript basics for React! From typing props to custom hooks, this guide offers beginner-friendly insights to elevate your React development.


Why Typescript in React

TypeScript is a superset of JavaScript that adds static typing on top of JavaScript. TypeScript, when used with React, offers numerous benefits. These include static typing, which catches bugs during development/runtime, intelligent autocompletion, and self-documenting code with type annotations. Developers can understand the intent of a component without checking the implementation details, which increases productivity and enables smoother collaboration in the long run.

Note: When using TypeScript with React.js, you need to let TypeScript handle the types for you as much as possible. Only step in and define types when TypeScript cannot figure out types on its own.

Typing Props

Many of the components we write will accept the prop object, usually named NameOfComponentProps.

Let's see an example of a single todo component which accepts todo data and renders a todo component.

/components/Todo.tsx
type TodoProps = {
  id: number;
  title: string;
  completed: boolean;
};
export const Todo = (props: TodoProps) => {
  return (
    <div>
      <h4>{props.title}</h4>
      <p>{props.completed ? "Completed" : "Not Completed"}</p>
    </div>
  );
};

Instead of writing props every time, we can destructure properties from our props.

/components/Todo.tsx
type TodoProps = {
    id: number;
    title: string;
    completed: boolean;
};
export const Todo = ({ title, completed }: TodoProps) => {
    return (
        <div>
            <h4>{title}</h4>
            <p>{completed ? "Completed" : "Not Completed"}</p>
        </div>
    );
};
 

Now let's make some props optional.

/components/Todo.tsx
type TodoProps = {
    id: number;
    title: string;
    completed?: boolean;
};
export const Todo = ({ title, completed }: TodoProps) => {
    return (
        <div>
            <h4>{title}</h4>
            <p>{completed ? "Completed" : "Not Completed"}</p>
        </div>
    );
};

Now the completed prop is optional; we can choose not to pass it.

Let's say we want to provide a default value in case completed is not passed. We can set default values for our props as shown below.

/components/Todo.tsx
type TodoProps = {
    id: number;
    title: string;
    completed?: boolean;
};
export const Todo = ({ title, completed = false }: TodoProps) => {
    return (
        <div>
            <h4>{title}</h4>
            <p>{completed ? "Completed" : "Not Completed"}</p>
        </div>
    );
};

Now, if we don't pass the 'completed' prop, its value will be false.

Typing children Props

It is common in most React applications to have components with children props. These can include layout components, grid components, or card components which layout passed components.

In such cases, if we are sure we are passing a single JSX element, we can type our children props with JSX.Element.

/layouts/Layout.tsx
const Layout = ({ children }: { children: JSX.Element }) => {
  return <main>{children}</main>;
};
 
export default Layout;
 

TypeScript will complain if you try to pass multiple JSX elements. In that case, you can make the children prop of type JSX.Element an array.

/layouts/Layout.tsx
const Layout = ({ children }: { children: JSX.Element[] }) => {
    return <main>{children}</main>;
};
 
export default Layout;

Now you can pass multiple JSX elements to your layout, but you can still run into an issue when rendering primitive types like strings, numbers, etc.

To solve such issues, we can type our children props with React.ReactNode so that we can pass a single or multiple children props, including primitive types, together as children.

/layouts/Layout.tsx
const Layout = ({ children }: { children: React.ReactNode }) => {
return <main>{children}</main>;
};
 
export default Layout;

Another good alternative to React.ReactNode is using the built-in PropsWithChildren type, which internally does what we just did above with other prop types extension if needed.

/layouts/Layout.tsx
const Layout = ({ children }: PropsWithChildren) => {
    return <main>{children}</main>;
};
 
export default Layout;
 

Now, what do we do if we want other props, for example, a title for our page? We can extend them. It looks like this.

/layouts/Layout.tsx
type LayoutProps = {
    title?: string
}
const Layout = ({ title = "Task Manager", children }: PropsWithChildren<LayoutProps>) => {
    return <>
        <Helmet>
            <title>{title}</title>
        </Helmet>
        <main>{children}</main>;
    </>
};
 
export default Layout;
 

To keep it clean, I prefer to do it like this.

/layouts/Layout.tsx
type LayoutProps = PropsWithChildren<{
    title?: string
}>
const Layout = ({ title = "Task Manager", children }: LayoutProps) => {
    return <>
        <Helmet>
            <title>{title}</title>
        </Helmet>
        <main>{children}</main>;
    </>
};
 
export default Layout;
 

Extending Props

Let's say we are building a button component. A button component can have multiple attributes like type, onClick, disabled, and many more. Typing all of them is not a great experience. We can use utility helpers here.

/components/Button.tsx
import { ComponentPropsWithoutRef } from "react"
 
type ButtonProps = ComponentPropsWithoutRef<"button">
export const Button = ({ children, ...props }: ButtonProps) => {
    return <button {...props}>{children}</button>
}

We can use the ComponentPropsWithoutRef type and pass the element type to get all the properties of the element.

Typing useState

If you have an initial type for your useState, TypeScript can infer the type of your state. For example:

const [count, setCount] = useState(0)

Here, TypeScript can infer the type of count and setCount you don't need to explicitly type this.

const [count, setCount] = useState()

However, here TypeScript needs your help. Currently, the type of count is undefined, and the type of setCount is React.Dispatch<React.SetStateAction<undefined>>. To fix this, let's give them a type.

const [count, setCount] = useState<number>()

TypeScript will now give count a type of undefined | number, allowing you to set a number to your state.

You can also assign non-primitive types to your useState. Let's see an example.

/components/Todo.tsx
export const Todo = () => {
    const [todo, setTodo] = useState<Todo | undefined>()
 
    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/todos/1')
            .then(res => res.json()).then(data => setTodo(data));
    }, [])
 
    if (!todo) return <p>Loading...</p>
 
    return (
        <div>
            <h4>{todo.title}</h4>
            <p>{todo?.completed ? "Completed" : "Not Completed"}</p>
        </div>
    );
};

Typing Reducers

First things first, when defining types for reducers, the main parts are type definitions for state and action types.

Let's start by defining the type for our todo manager state.

/reducers/todo-reducer.ts
type InitialState = {
  id: number;
  title: string;
  completed: boolean;
};
 
const initialState: InitialState[] = [];

Now let's create action types for create, and delete todo actions.

/reducers/todo-reducer.ts
type AddTodoAction = {
  type: "addTodo";
  payload: {
    title: string;
  };
};
 
export type DeleteTodoAction = {
  type: "deleteTodo";
  payload: {
    id: number;
  };
};
 
type ActionType = AddTodoAction | DeleteTodoAction;

Now, thanks to our types, we can get IntelliSense support and switch over our actions to update our state.

Our reducer function can look like this:

/reducers/todo-reducer.ts
export const todoReducer = (state: Todo[] = initialState, action: AddTodoAction | DeleteTodoAction): Todo[] => {
  switch (action.type) {
    case "addTodo":
      return [
        ...state,
        {
          id: state.length,
          title: action.payload.title,
          completed: false,
        },
      ];
    case "deleteTodo":
      return state.filter((todo) => todo.id !== action.payload.id);
    default:
      return state;
  }
};

Let's use our reducer inside our component.

/src/App.tsx
function App() {
  const [todos, dispatch] = useReducer(todoReducer, initialState)
 
  return (
    <Layout>
      <div style={{ display: "flex", flexDirection: "column", justifyContent: "center", justifyItems: "center", alignItems: "center" }}>
 
        <AddTodo onSubmit={dispatch} />
        {
          todos.map(todo => <Todo key={todo.id} id={todo.id} title={todo.title} completed={todo.completed} deleteTodo={dispatch} />)
        }|
      </div>
    <Layout>
    )
}

Now let's create AddTodo component and also lets update the Todo component.

/components/AddTodo.tsx
export const AddTodo = ({ onSubmit }: { onSubmit: React.Dispatch<AddTodoAction> }) => {
    const [title, setTitle] = useState("")
 
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        onSubmit({ type: "addTodo", payload: { title: title } })
        setTitle("")
    }
 
    return <form onSubmit={handleSubmit}>
        <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
        <button>Add Todo</button>
    </form>
}

Let's update our Todo component.

/components/Todo.tsx
type TodoProps = {
    id: number;
    title: string;
    completed: boolean;
    deleteTodo: React.Dispatch<DeleteTodoAction>
};
export const Todo = ({ id, title, completed, deleteTodo }: TodoProps) => {
    return (
        <div style={{ display: "flex", columnGap: "0.5rem", justifyContent: "center", justifyItems: "center", alignItems: "center" }}>
            <h4>{title}</h4>
            <p>{completed ? "Completed" : "Not Completed"}</p>
            <button onClick={() => deleteTodo({ type: "deleteTodo", payload: { id } })}>Delete Todo</button>
        </div>
    );
};

Typing Custom Hooks

Now, let's load our todos from an API and create a custom useTodos hook. We can add loading and error states to indicate the status of our API call.

/hoooks/use-todos.ts
interface Todo {
  id: number;
  title: string;
  completed: boolean;
}
 
const useTodos = () => {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    const fetchTodos = async () => {
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/users/1/todos");
        if (!res.ok) {
          throw new Error("Failed to fetch todos");
        }
        const data: Todo[] = await res.json();
        setTodos(data);
      } catch (error) {
        setError("Something went wrong");
      } finally {
        setLoading(false);
      }
    };
 
    fetchTodos();
  }, []);
 
  return { todos, loading, error };
};
 
export default useTodos;

Now let's use our custom hook inside our App component.

function App() {
  const { todos, loading, error } = useTodos();

  if (loading) {
    return <p>Loading...</p>;
  }

  if (error) {
    return <div>{error}</div>;
  }

  return (
    <Layout>
      <div style={{ display: "flex", flexDirection: "column", justifyContent: "center", justifyItems: "center", alignItems: "center" }}>
        {
          todos.map(todo => <Todo key={todo.id} id={todo.id} title={todo.title} completed={todo.completed} />)
        }|
      </div>
    </Layout >
  )
}

export default App