All notes below are taken from the official docs, the best resource to learn React!

Components

React lets us declare components and reuse them across our frontend application. These components have to return JSX. Which is just fancy HTML that can contains Javascript code inside. Here’s an example of a React component that returns an image of a dog if the user likes dogs, or an image of a generic animal otherwise.

const user = {
    preferences: {
        favouritePet: 'dog'
    }
}
 
const DisplayPet = () => { 
    return <>
        {/* this comment, alongside the tertiary operator down below, are JS code! */}
        <img
            src={`/assets/${user.preferences.favouritePet == 'dog' ? 'dog' : 'generic'}.png`}
        />
    </>
}
 
export default DisplayPet;

Hooks

Components use hooks to add fancy logic to their code. Anybody can create hooks, they’re regular JS functions with two special rules:

  1. they have to be called at the top-level of a component (not in JSX, not in any branches, loops or other functions - they basically have to be called in the same order on every re-render)
  2. they cannot be called from non-React or non-functional components (yes class components are a thing)

The useState Hook

This lets your component retain a piece of data across renders. React re-renders components often throughout the lifetime of your application. Every time a component is re-rendered, the whole function is executed again, therefore variables are reset. If we want to preserve a variable, we have to use a state. Think of a state as a snapshot of data that is only changed on re-render. The useState hook returns two values, a variable (which is persisted across renders) and a function (which lets us set the value, and any time it’s set, render the component again, so that the value may be seen on the UI).

One important caveat with useState is that when you set your state, the component will re-render with the new value, but the current rendering won’t change! Think of a state as a snapshot of data that is only changed on re-render. Here’s an example:

import { useState, type ChangeEvent } from 'react';
const DisplayPet = () => { 
    const [favouritePet, setFavouritePet] = useState('dog'); // dog is the default value!
    const handleAnimalChange = (e: ChangeEvent<HTMLInputElement>) => {
        setFavouritePet(e.target.value);
        // on first run, this will always print 'dog'!
        // even after we changed the value to something else
        console.log(favouritePet); 
    }
 
    return <>
        <img
            src={`/assets/${favouritePet == 'dog' ? 'dog' : 'generic'}.png`}
            alt={`Closest thing to a ${favouritePet}`}
        />
        <br/>
        <label>Change your favourite animal: </label>
        <input type="text" defaultValue={favouritePet} onChange={handleAnimalChange}/>
    </>
}
 
export default DisplayPet;

In the code snippet, above, after the user clears the textbox, and enters “cat”, the console will look like this:

dog
 
c
ca

Think about why “cat” is not printed!

React Rendering Steps

  1. Trigger (on initial render, or on state update)
  2. Render:
    • On initial render: call the root component (which will call every child component recursively) & create DOM nodes for each of them
    • On re-render: call component whose state has changed & calculate which of their properties has changed since previous render.
  3. Committing:
    • On initial render: put all the DOM nodes on screen
    • On re-render: apply minimal necessary operations to make the DOM match the latest rendering output

Batching

All state updates that happen in a continuous piece of code (event handlers, async function continuations, promises, etc) are actually batched together! That means the UI won’t be updated until after the related code completes.

This could cause some issues if you need the newly updated state right away, before the re-render. We can actually get this value by passing a function to our set function that takes in the previous set value as an argument.

These two code snippets may look like they do the same thing, but they actually do completely different things.

// assume number is 0
setNumber(number + 1); // setNumber(0+1)
setNumber(number + 1); // setNumber(0+1)
setNumber(number + 1); // setNumber(0+1)
 
// assume number is 0
setNumber(n => n + 1); // setNumber(0+1)
setNumber(n => n + 1); // setNumber(1+1)
setNumber(n => n + 1); // setNumber(2+1)
 
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42); // setNumber(n => 42), so n is unused here
// at the end, number will be 42!

When React executes these set function calls, they’re added to a queue, that’s then consumed on the next render’s useState call.

States are immutable

You should treat states as immutable, even if your state value is actually a mutable object like a string. As such, states should be treated as read-only.

const [position, setPosition] = useState({ x: 0, y: 0 });
position.x += 1; // this is illegal!
setPosition(p => ( {...p, p.x + 1} )) // correct way of updating

Mutating states like that doesn’t actually trigger a re-render and may cause nasty bugs. In a nutshell, not mutating things solves a bunch of headaches. When using arrays, you’re going to want to use methods that don’t cause mutations.

Declarative vs Imperative

One thing to realize after all this is that React uses a declarative paradigm, not an imperative paradigm. You don’t tell React what to do, but you lay out some states and the triggers for each state change, then the framework takes care of the rest. In vanilla JS instead, you do actually command every action (show this button on input change, hide that specific element, etc
)

SSSSS rule (State Should Stay Simple Stupid)

Make sure your component only has states that it needs. Any of the following states will make your code error-prone:, that don’t contradict each other, that are not duplicated, or that can be calculated during render. Any of these will make your code error-prone.

  • contradicting state: having two states isSending and isSent. If at any point, both are true, it’ll cause a paradox. Instead, use a status state that can take one of three valid values: typing, sending and state.
  • duplicated state: having items and selectedItem where selectedItems actually stores one of the objects in items. Instead, use selectedItemId to point to one of the objects in items.
  • can be calculated during render: having firstName, lastName, and fullName as states. fullName does not need to be a state, it can be calculated during render.
  • deeply nested states: these are hard to reason about and require to recursively use the spread operators when updating the state.

It’s also a good idea to group up states that you update together into one single state, so that it’s easier to keep them in sync. Whenever your states break any of the rules above, normalize them to make ‘em 5S!

One Single Truth for the State

For a specific state, there should be one single source of truth. Therefore, states can and should be passed around as props if needed. To keep using the Inversion of Control philosophy, the set function should only be used in the component that owns the state though, as child components that use the state shouldn’t be concerned with state update logic.

That’s why in the component below, SearchBar takes in handleChange, and not setQuery.

export default function FilterableList() {
  const [query, setQuery] = useState('');
  const results = filterItems(foods, query);
 
  const handleChange = e => setQuery(e.target.value);
  return (
    <>
      <SearchBar query={query} onChange={handleChange} />
      <List items={results} />
    </>
  );
}

How does React keep track of states?

React keeps track of which state belongs to which component based on their place in the UI tree (not in the JSX, but in the UI tree!). For example, this app has two counters with two different states! Even if it’s rendering the same component object.

const counter = <Counter />;
  return (
    <div>
      {counter} 
      {counter} {/* different state than the counter above! */}
    </div>
  );

This comes with a few implication.

const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
  {isPlayerA ? (
	<Counter person="Taylor" />
  ) : (
	<Counter person="Sarah" />
  )}
  ...
</div>
)

In the app above, the counter will always be the same even if you change person! This is because the component is rendered in the same place on the UI tree. One way to fix this is by using the key prop:

...
  <Counter key="Sarah" person="Sarah" />
...

key is useful whenever you want to tell React to re-create the DOM instead of reusing it. When displaying lists, it’s important to never use their index for the key, because if the elements change order, the state won’t actually change since it’ll be index based, not element-based.

The useReducer hook

If you have lots of different and complicated state updates in your code, it might be worth it to look into using useReducer instead of useState. In your component, instead of focusing on how to update the state, you just have to worry about what actually happens. The state updating logic can live elsewhere.

Reducers must be pure, and each action should describe a single user interaction.

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
 
  function handleAddTask(text) {
    dispatch({ type: 'added', id: nextId++, text: text });
  }
 
  function handleChangeTask(task) {
    dispatch({ type: 'changed', task: task });
  }
 
  function handleDeleteTask(taskId) {
    dispatch( type: 'deleted', id: taskId });
  }
 
  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}
 
// tasksReducer
export default function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    }
    case 'changed': {
      return tasks.map((t) => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

The useContext hook

Prop-drilling is the a phenomenon that occurs when you have to pass props deep down your UI tree. Let’s just this is is not that convenient. An alternative to this is using context.

import {useContext} from 'react';
 
export default function App() {
	const [isLarge, setIsLarge] = useState(false);
	return (
		<ImageSizeContext value={isLarge ? 150 : 100}>
			<List/>
		</ImageSizeContext>
	)
}
 
// ...a bunch of components where the imageSize would otherwise be drilled down...
 
function MyImage() {
  const imageSize = useContext(ImageSizeContext);
  return (
    <img
      src={...}
      width={imageSize}
      height={imageSize}
    />
  );
}
 
// our ImageSizeContext.js
import {createContext} from 'react';
export const ImageSizeContext = createContext(100);

Using context may be tempting but it hides the data flow and it makes your code harder to maintain. It’s usually used for global state like themes, current user and routing.

Using context, reducer and custom hooks together

Here’s an example of a to-do app, defining a reducer (for the tasks) and a context (for the reducer) so that components down the UI tree can read the tasks and dispatch events on them.

Let’s define our context and reducer in the same component.

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
 
  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        {children}
      </TasksDispatchContext>
    </TasksContext>
  );
}
 
/*
We can even define custom hooks so that children don't even need to write `useContext(bla bla)`
*/
export function useTasks() {
  return useContext(TasksContext);
}
 
export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

Then, in our app:

import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';
 
export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Day off in Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

Our task list can read tasks from the context using our custom hook, and our task component can do the same to dispatch events!

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}
 
function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useTasksDispatch();
  ...
}

The useRef hook

If you need a state but you don’t want to trigger new renders when changing its value, use useRef. It could be very useful when you need to build a stopwatch for example, and you need to clear a setInterval or setTimeout.

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  // we just need to keep track of the intervalId without rerendering
  const intervalRef = useRef(null); 
 
  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());
 
    clearInterval(intervalRef.current);
    // as you can see, refs are MUTABLE!
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }
 
  const handleStop = () => clearInterval(intervalRef.current);
 
  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }
 
  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
      <button onClick={handleStop}>
        Stop
      </button>
    </>
  );
}

Refs are also used to reference to specific DOM nodes, for example to focus a node, scroll to it, or measure its size/position.

export default function Form() {
  const inputRef = useRef(null);
  function handleClick() { inputRef.current.focus(); }
  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}> Focus the input </button>
    </>
  );
}

The useEffect hook

Effects let us specify side effects that are caused by rendering itself, rather than by a particular event. useEffect takes in one mandatory value (a function to run) and an optional dependency list. By default, an effect runs after every render. The list of dependencies changes this by telling React to run our effect only after one of those dependencies changes. The effect function can also return a cleanup function, used in cases where we connect to an external system and then the cleanup function disconnects.

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
 
  useEffect(() => {
    if (isPlaying) { ref.current.play(); } 
    else { ref.current.pause(); }
  });
 
  return <video ref={ref} src={src} loop playsInline />;
}

Effects should be used sparingly.

  • You don’t need Effects to transform data for rendering
  • You don’t need Effects to handle user events Generally, if there’s no external system involved, don’t use effects.

The useEffectEvent hook

Effects are reactive. They run whenever one of their dependencies changes. Whenever we mix reactive logic with non-reactive logic (the user making an action for example).

If we want to read some non-reactive data from our effect, but we don’t want to add it to our dependency list, then we can use the useEffectEvent() hook.

function ChatRoom({ roomId, theme }) {
  // our effect can read theme without re-running on theme change
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });
 
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ No need for theme!
  // ...

Other times, it might be useful to move in some data inside of our Effect, so that it doesn’t depend on it anymore.

pro tip: as your apps grows, write your own custom hooks to write less and less Effects from scratch :)