React Hooks: A Companion Guide for React Beginner Projects

Uci Lasmana
20 min readFeb 4, 2024

--

This guide is specifically designed to provide you with the foundational knowledge of React Hooks for the ‘React Beginner Projects’ tutorials which focus on the practical implementation of Hooks in various projects.

Before diving into Hooks, we need to know about state, prop, event handler, and rendering in React. Let’s explore them first!

State

State is a component’s memory that keeps information. The state can hold any JavaScript value, including arrays and objects. However, there is something different about how we treat arrays and objects in state. In React we treat all states as immutable, including arrays and objects, even if they are mutable.

When we want to update an object or update an array stored in a state, we need to create a new one or make a copy of an existing one, then set the state to use that copy.

We shouldn’t directly change objects or arrays in the React state. Mutating them will not trigger renders, which will cause the UI not to represent the component’s data accurately. We need to treat objects and arrays in state as read-only.

However, there are times when we want to include existing data as part of a new object or array. In this situation, we can use the spread syntax .... Look at this example below:

//Object spread syntax to create copies of objects
setObject({
...obj, // Copy the old fields
something: 'newValue'// But override this one
});

//Array spread syntax to create arrays with new items
setArray(
[
...arr, // Copy the old fields
newItem// and one new item at the end
]
);

Spread syntax is shallow. It only copies one level deep.

Props

Props are pieces of information that you pass to a JSX tag. React components use props to communicate with each other. Every parent component can pass some information to its child components by giving them props. Props can be any JavaScript value, including objects, arrays, and functions.

//Here we passed 2 props, person and size.

<Avatar
person={person}
size={size}
/>

Notes:

  • Every render receives a new version of props. This ensures that the component always displays the most up-to-date information based on the props passed to it from its parent component.
  • Props are read-only, you can’t change props. Props allow components to receive information and customize their behavior.

If you need to change a value, what you actually need is not props, but state. Use state for managing dynamic data within a component.

Event Handler

An event handler is a function that will be triggered in response to interaction such as clicking, hovering, or focusing on form inputs.

There are two ways to write an event handler:

1. Add an event handler by defining a function and then passing it as a prop to the appropriate JSX tag. Look at this example below:

export default function Button() {
function handleCount() {
count=count+1
}
return (
<button onClick={handleCount}>
Count Me
</button>
);
}

2. Add an event handler by defining a function inline in the JSX.

<button onClick={function handleCount() {
count=count+1
}}> Count Me </button>

//We can also use an arrow function
<button onClick={() => {
count=count+1
}}> Count Me </button>

Notes:

  • Event handler functions have names that start with “handle”, followed by the name of the event, such as handleClick and handleCount.
  • An event handler is defined inside a component, allowing it to access props.
  • We can declare an event handler in a parent component and then pass it to a child component as a prop.
  • When passing an event handler as a prop, we should only pass it without invoking it. Look at this comparation below:
  • Event handler props should start with on, followed by a capital letter, like onClick, onSmash, etc.
  • The built-in browser elements, such as <button>, <div>, or <form>, only support the standard browser event names, such as onClick, onChange, or onSubmit.
import { useState } from 'react';

export default function Form() {
const [name, setName] = useState('John Doe');

return (
<>
<label>
Name:
{/*We use onChange handler so we can update the name state as we type.
This ensures that the input field reflects the user’s input,
and the name state stays in sync. */}
<input
value={name}
onChange={e => setName(e.target.value)}
/>
</label>
<span>{name}</span>
</>
);
}

Rendering

Rendering is the process when React calls your components to figure out what to display on the screen. When rendering occurs, React will construct and update the UI based on the component’s data and logic. It ensures that users see an accurate representation of the application’s state.

There are three steps of how React renders components on screen:

1. Triggering a render

A component renders for two reasons. First during its initial render and second when the component’s state or its parent component’s state is updated.

Updating your component’s state automatically queues a render. This means when we update multiple states within a function component, React batches those state updates together and performs a single re-render.

Check this example below:

We have three states, stateA, stateB, and stateC. If we update all three states within an event handler or any other function, React doesn’t immediately re-render the component for each individual state change.

Instead, React queues up these state updates as a batch. It waits until all synchronous code (including event handlers) has finished executing. Once the call stack is clear, React processes the scheduled re-render for the entire batch of state changes.

After batching the state updates, React performs a single re-render to reflect the combined changes. It efficiently computes the new virtual DOM and updates the actual DOM accordingly.

This batching behavior is an optimization. It minimizes the number of re-renders and ensures that the UI remains responsive. Imagine if React triggered a separate re-render for each state update, it would be inefficient and lead to unnecessary rendering cycles.

2. Rendering the component

In this step, React will call the function component whose state update triggered the render. During the initial render, React creates the DOM nodes.

During a re-render, React will calculate which of their properties have changed since the previous render. However, it doesn’t take any action based on that information until the next step: the commit phase.

The rendering process in React is recursive. When an updated component returns another component, React renders that component. If that component also returns additional components, React continues to render them, and so on. The process will continue until there are no more nested components.

3. Committing to the DOM

After rendering the components, React will modify the DOM. For the initial render, React will use the appendChild() DOM API to put all the DOM nodes it has created on the screen. And for re-renders, React only updates the DOM nodes if there’s a difference between renders. React will compare the new and old virtual DOM and update the real DOM only for the parts that have changed.

The Virtual DOM is a representation of the UI that is kept in memory and synced with the real DOM. Updating the virtual DOM is much faster than updating the real DOM because there’s nothing to display in the UI. The real DOM will be updated after React compares the difference between the new and old virtual DOM.

After rendering is complete and React has updated the DOM, the browser will repaint the screen (browser rendering).

The Hooks

Hooks are special functions that allow functional components to manage state, side effects, and other React features without writing class components. We can use built-in Hooks or combine them to create our own custom Hooks.

Notes:

  • Hooks must be called at the top level of your functional component or within custom hooks that you create.
function MyComponent() {
//Top-level placement
const [count, setCount] = useState(0);
...
}
  • You cannot call hooks inside loops, conditions, event handlers, nested functions or any non-top-level code.
//Call hook inside condition
function MyComponent() {
if (someCondition) {
const [count, setCount] = useState(0);
}
...
}

//Using hooks inside loop
function MyComponent() {
const items = ['Apple', 'Banana', 'Orange'];
const [count, setCount] = useState(0);
return (
<div>
<h1>Incorrect Item Counters</h1>
{items.map((item) => (
<div key={item}>
<h3>{item}</h3>
<p>Count: {count}</p>
{/* All items in the loop share the same state (count),
which will display the same count value.
Each item should have its own independent state*/}
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
))}
</div>
);
}

If you need to use hooks inside a loop, condition, or any other non-top-level code, extract that logic into a separate component. Create a new functional component and move the state and hooks into it.

From the example of using hooks inside loop code above, I’ll show you how to fix it by creating a separate component to manage the state for each item individually:

ItemCounter.js

import React, { useState } from 'react';
const ItemCounter = ({ itemName }) => {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
return (
<div>
<h3>{itemName}</h3>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
export default ItemCounter;

ItemList.js

import React from 'react';
import ItemCounter from './ItemCounter';
const ItemList = () => {
const items = ['Apple', 'Banana', 'Orange'];
return (
<div>
<h1>Item Counters</h1>
{items.map((item) => (
<ItemCounter key={item} itemName={item} />
))}
</div>
);
};
export default ItemList;

Alright, now let’s talk about built-in Hooks.

State Hooks

There are two hooks that can be used to add state to a component:

useState

useState is a hook that lets us add a state variable in the component. This hook returns an array with two values, which are the current state and the set function.

This is how we define the useState:

const [state, setState] = useState(initialState)

Notes:

  • During the first render, the current state will match the initialState we passed. initialState is the starter value of the state.
  • The initialState can be a value of any type including a function. The function that gets passed as an initialState will be an initializer function. React will call the function when initializing the component and store its return value as the initial state. This argument is ignored after the initial render.
//This is the correct way to use initializer function:
const [todos, setTodos] = useState(createInitialTodos);

//Not like this:
const [todos, setTodos] = useState(createInitialTodos());
//Although the result of createInitialTodos() is only used
//for the initial render, we're still calling this function on every render

The initializer function should take no arguments, should return a value of any type, and should be pure (the function should always return the same output for the same input and should not modify any external state or have any side effects),

  • The set function lets you update the state to a different value and trigger a re-render. We can pass the next state directly, or a function that calculates it from the previous state.

If you pass a function as the next state, it will be treated as an updater function. It must be pure, should take the current state as its only argument, and should return the next state.

  • The set function only updates the state variable for the next render. If you read the state variable after calling the set function, you will still get the old value that was on the screen before your call.
//This is the incorrect way to update the state
function handleClick() {
setAge(age + 1); // setAge(42 + 1)
setAge(age + 1); // setAge(42 + 1)
setAge(age + 1); // setAge(42 + 1)
//After one click, age will only be 43 rather than 45
//Calling the set function does not update the age state variable
//in the already running code
}

//This is the correct way to update the state by calculating it from
//the previous state. We pass an updater function to setAge instead
//of the next state
function handleClick() {
setAge(a => a + 1); // setAge(42 => 43)
setAge(a => a + 1); // setAge(43 => 44)
setAge(a => a + 1); // setAge(44 => 45)
}
  • If the new value you provide is identical to the current state, as determined by an Object.is comparison, React will skip re-rendering the component and its children. This is an optimization.
  • You can reset a component’s state by passing a different key to a component.
import { useState } from 'react';

export default function App() {

//The Reset button changes the version state variable,
//which we pass as a key to the Form
const [version, setVersion] = useState(0);

//When the key changes, React re-creates the Form component
//including all of its children from scratch, so its state gets reset
function handleReset() {
setVersion(version + 1);
}

return (
<>
<button onClick={handleReset}>Reset</button>
<Form key={version} />
</>
);
}

function Form() {
const [name, setName] = useState('Taylor');
return (
<>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
<p>Hello, {name}.</p>
</>
);
}

useReducer

useReducer is a React Hook that lets you add a reducer to your component.

A reducer is a single function outside component that keeps all of logic in one place. With useReducer, we can extract the state logic from the components into a separate reducer function.

This is how to define useReducer:

const [state, dispatch] = useReducer(reducer, initialArg, init?)

useReducer returns an array with two elements, the current state and a dispatch function. The dispatch function is a function that updates the state to a different value and trigger a re-render.

useReducer is called with three arguments: a reducer function, an initialArg, and an init function as an optional.

  • A reducer is a function that is used to manage the state and it takes two parameters, the current state and an action, then returns the new state.
  • initialArg is the initial state that we passed to useReducer.
  • The init function is used to set up the initial state. When you use the init function, the initialArg is passed to the init function, which then returns the actual initial state.
  • If you don’t use an init function, the initialArg is used directly as the initial state.
  • When defining a reducer avoid recreating the initial state.
function createInitialState(username) {
// ...
}
//The correct way
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, username, createInitialState);

//The incorrect way
const [state, dispatch] = useReducer(reducer, username, createInitialState(username));

Notes:

  • React will pass the current state and the action to the reducer function. The reducer will calculate and return the next state. React will store that next state, render your component with it, and update the UI.
//The example of a reducer function
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
return {
name: state.name,
age: state.age + 1
};
}
case 'changed_name': {
return {
name: action.nextName,
age: state.age
};
}
default:
throw Error('Unknown action: ' + action.type);
}
}

It is common to write the reducer function as a switch statement to handle the actions. For each case in the switch, it will return some next state.

  • The type property specifies the type of the action that we want to perform.
  • When we want to use the reducer function in a component we can call the dispatch function inside an event handler.
function Form() {
const [state, dispatch] = useReducer(reducer, { name: 'Taylor', age: 42 });

function handleButtonClick() {
dispatch({ type: 'incremented_age' });
}

function handleInputChange(e) {
dispatch({
type: 'changed_name',
nextName: e.target.value
});
}
// ...

Actions can have any type of value. But it’s common to pass objects with a type property identifying the action. It should include the minimal necessary information that the reducer needs to compute the next state.

  • When we dispatched an action but logging still gives us the old state value, we can manually call the reducer. This all because when we are updating the state, it does not affect the state variable in already-running event handler.
function handleClick() {
console.log(state.age); // 42
dispatch({ type: 'incremented_age' }); // Request a re-render with 43
//Updating state does not affect the state variable in already-running code
//The updated state value is not available until the next render
console.log(state.age); // Still 42!
}

//If we need to guess the next state value, we can calculate it
//manually by calling the reducer function
const action = { type: 'incremented_age' };
dispatch(action);

const nextState = reducer(state, action);
console.log(state); // { age: 42 }
console.log(nextState); // { age: 43 }

useReducer is very similar to useState, but it lets you move the state update logic from event handlers into a single function outside of your component. This means we can manage multiple states using a single reducer function, which can reduce the complexity of state logic.

useReducer can also help optimize the performance. When we want to pass the dispatch function down to child components, we can pass it directly and it would not change between re-renders.

useContext Hook

useContext is a React Hook that lets you read and subscribe to context from your component. To use useContext we need to define the context first.

  1. Create Context:

Context is a mechanism for sharing data between components without having to pass it explicitly through props. It allows you to create a global state that can be accessed by any component in your application.

Context prevents “prop drilling”, which is the process of passing data from one component to other components based on hierarchy, until the data reaches the target component, even if the other components don’t need the data.

Context lets a component receive information from top-level components to lower-level components without worrying about component hierarchy.

This is how we create a context:

const SomeContext = createContext(defaultValue)

Notes:

  • defaultValue is the initial value that the context will have when there is no matching context provider above the component that reads the context. When we don’t have any meaningful default value, we specify it as null.

When a component tries to access context data using useContext, React looks up the component tree to find the closest context provider, then uses the context data. If there is no context provider, the defaultValue will be used. Look at this example below:

const ThemeContext = createContext('light'); // Default theme is 'light'

// In the component tree:
<ThemeContext.Provider value="dark">
{/* Components */}
</ThemeContext.Provider>

If a component deeper in the tree tries to access the theme context but there’s no theme provider wrapping it, it will use the defaultValue (‘light’). And if we forget to specify value, the default value is undefined.

  • createContext returns a context object.
  • SomeContext is the context object and it does not hold any information. It only represents the kind of information you can provide or read from components.

2. Provide Context Data

After we create context, we wrap the component tree with a context provider. With the provider, we can pass the data we want to share via the value prop.

function App() {
const [activity, setActivity] = useState(null);
return (
<SomeContext.Provider value={activity}>
<ChildComponent1/>
<ChildComponent2/>
</SomeContext.Provider>
);
}

Note:

  • We can override the context provider value by wrapping that part in a provider with a different value.
<ThemeContext.Provider value="dark">
...
<ThemeContext.Provider value="light">
<Footer />
</ThemeContext.Provider>
...
</ThemeContext.Provider>
<ThemeContext.Provider>

3. Consume Context Data

To use the context in child components, we need to access it using the useContext. This is how we call the useContext:

const value = useContext(SomeContext)

Notes:

  • useContext returns the context value from the context we passed which is SomeContext.
function ChildComponent1() {
const value = useContext(SomeContext)
return (
<h1>This is the value : {value}</h1>
);
}
  • With useContext, the child component will only re-render when the context value changes. Usually when a parent component re-renders, every child component will re-render too even if the components don’t depend on the update state or props.

useRef Hook

useRef is a React Hook that returns a ref object with a single current property which is set to the initial value. With ref we can keep information that we don’t want to trigger new renders, this is because ref is a plain JavaScript object. React is not aware when we change it.

We can mutate the ref.current property. Unlike state, it is mutable. But if it holds an object that is used for rendering, then you shouldn’t mutate that object.

This is how we call the useRef:

const ref = useRef(initialValue)

Notes:

  • initialValue is the value we want the ref object’s current property to be initially. It can be a value of any type. This argument is ignored after the initial render.
  • Avoiding recreating the ref contents like this:
const ref = useRef(initialValue())
//The result is only used for the initial render,
// but we're still calling this function on every render.
  • To update the value inside the ref, we need to manually change its current property.
import { useRef } from 'react';

export default function Counter() {
let ref = useRef(0);

function handleClick() {
//This is how to update the value inside the ref
ref.current = ref.current + 1;
alert('You clicked ' + ref.current + ' times!');
}

return (
<button onClick={handleClick}>
Click me!
</button>
);
}
  • We can use a ref to manipulate the DOM. To do this, we need to declare a ref object with an initial value of null , then pass your ref object as the ref attribute to the JSX.
import { useRef } from 'react';

function MyComponent() {
const inputRef = useRef(null);
//How to access the DOM
function handleClick() {
inputRef.current.focus();
}

// ...
return <input ref={inputRef} />;

After React creates the DOM node and puts it on the screen, React will set the current property to the DOM node. React will set the current property back to null when the node is removed from the screen.

  • If you pass the ref object to React as a ref attribute to a JSX node, React will set its current property.
  • Do not write or read ref.current during rendering, except for initialization.
//The incorrect ways for updating the ref value
function MyComponent() {
// ...
// Write a ref during rendering
myRef.current = 123;
// ...
//Read a ref during rendering
return <h1>{myOtherRef.current}</h1>;
}

//The correct ways for updating the ref value
function MyComponent() {
// ...
useEffect(() => {
// Read or write refs in effects
myRef.current = 123;
});
// ...
function handleClick() {
// Read or write refs in event handlers
doSomething(myOtherRef.current);
}
// ...
}
  • Don’t try to pass a ref to a component. A component doesn’t expose refs to the DOM nodes inside them. If you want to do it, then use forwardRef.
const inputRef = useRef(null);
//The incorrect way to pass a ref
return <MyInput ref={inputRef} />;

forwardRef

forwardRef lets your component expose a DOM node to parent component with a ref. By default, each component’s DOM nodes are private. But, sometimes you might need to interact directly with a DOM element or a child component from a parent component. This is when you need to use the forwardRef.

const SomeComponent = forwardRef(render)

Notes:

  • forwardRef accepts a render function as an argument. React calls this function with the props and ref that the component received from its parent. The JSX we return will be the output of your component.

app.js

import { useRef } from 'react';
import MyVideoPlayer from './MyVideoPlayer.js';

export default function App() {
//The App component defines a ref and passes it to
//the MyVideoPlayer component.
const ref = useRef(null);
return (
<>
<button onClick={() => ref.current.play()}>
Play
</button>
<button onClick={() => ref.current.pause()}>
Pause
</button>
<br />
{/*Passing a ref to MyVideoPlayer Component allows the app
component play and pause the <video> node within it */}
<MyVideoPlayer
ref={ref}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
type="video/mp4"
width="250"
/>
</>
);
}

MyVideoPlayer.js

import { forwardRef } from 'react';
//The MyVideoPlayer component forwards that ref
//to the browser <video> node.
const VideoPlayer = forwardRef(function VideoPlayer({ src, type, width }, ref) {
return (
<video width={width} ref={ref}>
<source
src={src}
type={type}
/>
</video>
);
});

export default VideoPlayer;

forwardRef returns a React component that you can render in JSX. Unlike React components defined as plain functions, a component returned by forwardRef is also able to receive a ref prop.

useEffect Hook

useEffect is a hook that lets you have side effects happen in your component. useEffect lets you connect a component to an external system. This includes dealing with network, browser DOM, animations, widgets written using a different UI library, and other non-React code.

If there is no external system involved, like if you want to update a component’s state when some props or state change, you shouldn’t need an Effect. Removing unnecessary Effects will make your code easier to follow, faster to run, and less error-prone.

This is how we define the useEffect :

useEffect(setup, dependencies?)

Notes:

  • useEffect has 2 arguments, setup and dependencies.
  • The setup is a function with the Effect’s logic. The dependencies are the list of all reactive values, like props, state, and all the variables and functions declared directly inside the component body.
useEffect(() => {
//Effect's logic
}, [dependency1, dependency2]);

React will compare each dependency with its previous value using the Object.is comparison. If the value is still the same, then useEffect doesn’t need to re-run.

If we have linter that configured for React, it will verify that every reactive value is correctly specified as a dependency.

  • If we want to let the effects re-run after every re-render of the component we don’t need to list any reactive values as dependencies for useEffect.
useEffect(() => {
//Effect's logic
});
  • And if we want the effect run only on the first render, then we can use an empty array.
useEffect(() => {
//Effect's logic
}, []);
  • The setup function may also optionally return a cleanup function. Some effects require cleanup to reduce memory leaks. Timeouts, subscriptions, event listeners, and other effects that are no longer needed should be cleaned up. We define the cleanup function by including a return function at the end of the useEffect.

When a component’s dependencies change, React triggers a re-render. During this phase, if we have provided a cleanup function, React runs it with the old values (before the update) and then runs the setup function with the new values.

However, if we don’t have any dependencies then the cleanup function is ready to run but doesn’t execute immediately. When we navigate away from the page, the cleanup function will be triggered.


const [count, setCount] = useState(0);

useEffect(() => {
const intervalId = setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);

// Cleanup function: Runs before the next effect
return () => {
clearInterval(intervalId); // Clear the interval
};
}, []);
  • If some of your dependencies are objects or functions defined inside the component, there is a risk that they will cause the Effect to re-run more often than needed. To fix this, remove unnecessary object and function dependencies.

Objects and functions are reference types. Even if their content doesn’t change, their reference (memory address) might. React compares dependencies by reference, not by content. If an object or function reference changes frequently (even if its content remains the same), the effect will re-run unnecessarily.

  • Avoid using a function created during rendering as a dependency. It’s better to declare functions inside the effect itself rather than including them in the dependency array.
// This function is created from scratch on every re-render
function createOptions() {
return {
serverUrl: serverUrl,
roomId: roomId
};
}

useEffect(() => {
const options = createOptions(); // It's used inside the Effect
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]);
//This effect re-connects after every render because the createOptions
//function is different for every render,
//as a result, these dependencies are always different on a re-render
...

//This is how we declare functions inside the effect itself
useEffect(() => {
function createOptions() {
return {
serverUrl: serverUrl,
roomId: roomId
};
}
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
});
  • useEffect returns undefined, and its effects only run on the client. They don’t run during server rendering.

During SSR(Server-Side Rendering), React renders components on the server before sending them to the client (the user’s browser). The server generates the initial HTML content based on the React components. This initial HTML includes the rendered output of the components, including any data or dynamic content. After the initial HTML is sent to the client, React takes over on the client side. React “hydrates” the components by attaching event handlers, setting up state, and making the page interactive. It runs the rendering code again to ensure that the client-side components match the server-rendered output. For hydration to work correctly, the initial render output (the HTML) must be identical on both the server and the client.

  • If the effect is doing something visual (for example, positioning a tooltip), and the delay is noticeable (for example, it flickers), replace useEffect with useLayoutEffect.

There’s so much more to discover and learn about React Hooks, but I hope this guide can help you gain basic knowledge as a beginner.

Here are some tutorials I’ve written that can help you implement the hooks in this guide:

  1. Build a Tic-Tac-Toe Game with React Hooks (React Beginner Projects Part 1). In this tutorial, we will learn how to use useState, useEffect, and useRef.
  2. Build a To-Do List App with React Hooks, Tailwind, and localStorage (React Beginner Projects Part 2). In this tutorial, we will use useState, useEffect, useContext, and useReducer, but we will focus more on how to use useContext and useReducer hooks.

Have a good day!

--

--