Jeremy Nwachukwu // Field notes

Modern React Masterclass: From Hooks to Server Actions

96 views June 10, 2026

Modern React Explained: From Beginner to Expert

React has dominated the web, and I think that is fair to say. It powers a huge number of sites on the internet, and it is used by big players like Facebook, Microsoft, Airbnb, Walmart, and millions of other companies and startups. But most people only use useState and useEffect, and they do not really understand the more complex parts of React. Like, who really knows what useMemo and useCallback are? If you do not know, this is a good place to start.

What is React

React is a JavaScript library primarily used for building frontend interfaces. It is often used with a single-page application rendering pattern, but it can also do other things, like power server-rendered apps and even mobile apps with React Native.

So now we know what React is, but what is a single-page app? A single-page app, or SPA for short, is an app that loads one main HTML page and then uses JavaScript to change what is on the page. For example, Twitch is one of the biggest SPAs, and it uses React.

Now you may be asking, "I have used Twitch before, and it has other pages. There is the home page, the login page, and others." But let me explain. The one-HTML-page idea does not mean it only has one screen. It uses JavaScript to dynamically change what is on the page. So basically, if you disable JavaScript, a lot of the site will not work as expected.

When you get deep enough into React, you hear about the virtual DOM, but what is that? The virtual DOM is an in-memory representation React uses to figure out UI changes before updating the real DOM. Instead of manually updating the DOM directly, React figures out what changed and updates the actual DOM efficiently.

What is a component

Besides the virtual DOM, React is popular for having components, which are simple units of UI. For example, this is a simple button component:

export const Button = () => {
  return ;
};

As you can see from the example above, the code for a button component is simple. This is a basic component. But if a component is just a function, how can it accept dynamic values that will be used when you use the component somewhere else? React has a name for that: props. Props are basically parameters for your component. That should be your mental model.

This is an example of props:

export const Button = ({ name }) => {
  return ;
};

// how it is used

You can also have multiple props in one component:

export const Button = ({ name, onClick, type = "button" }) => {
  return (
    
  );
};

// how to use it <Button name="Jemo" onClick={() => { console.log("Jemo"); }} type="submit" />;

But props can also be children:

export const Card = ({ children }) => {
  return 
{children}
; };

This content will render when the component is used. So how do you use it?


  

Jemo

;

Also, when rendering lists, you usually need a key prop.

A key prop helps React identify which elements in the array changed, were added, or were removed. If possible, make sure the key is a unique and stable ID.

Example of a key prop:

const UserList = ({ users }) => {
  return (
    
    {users.map((user) => (
  • {user.name}
  • ))}
); };

The example above renders a user list from a database, and as you can see, the key is user.id. That is usually something from your database table. Try not to use the array index as a key unless the list is truly static.

But do note that a React component cannot return multiple sibling elements directly unless they are wrapped. For example, this is wrong:

export const WrongComponent = ({ children }) => {
  return (
    
{children}
); };

That is wrong because it returns more than one top-level element. In a situation like this, you can wrap it with a div or <> </>, which is a fragment. You can use a fragment instead of a div because it adds less markup, and it will not affect your CSS layout the way an extra div can.

What is RSC

But there is a special type of component that is not for the client, but for the server.

If you do not know what RSC is or what I am talking about, I am talking about React Server Components.

So what are server components? Server components are components that run on the server and are rendered on the server, not on the client. Server components are useful when you want to render static parts of your site and fetch data from a database or API while keeping sensitive information away from the client and keeping the client JavaScript bundle smaller.

But there are some disadvantages. Most importantly, server components cannot use client-side interactivity or browser APIs, because they do not run in the browser.

One thing I want to mention: if you are using a framework that supports server components, they are often the default, and you need to use the client directive for interactive components, which looks like this:

"use client";

import { useState } from "react";

export function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}; }

What are server actions

Server actions are functions that run on the server but can be called from the client through frameworks that support them. But why choose this over a server component or a fetch call with an API route? There are some advantages.

It has RPC-like characteristics. You call the code from the client, but it runs on the server.

On some frameworks, the code can still work even if JavaScript is disabled.

Unlike API routes, server actions are not designed like public REST endpoints in the same way, which can reduce what is exposed.

Type safety can also be better with server actions since they are just functions in your app code.

Here is an example of a server action:

// actions.ts
"use server";

import { revalidatePath } from "next/cache"; import { db } from "./db";

export async function createTodo(formData) { const title = formData.get("title"); await db.todo.create({ data: { title } }); revalidatePath("/todos"); }

How it is used:

// TodoForm.tsx
import { createTodo } from "./actions";

export function TodoForm() { return (

); }

So that is the basic idea behind server actions and server components.

What are hooks

Hooks are ways to hook into React state and other React features inside function components.

But what is the React lifecycle? The React lifecycle is basically the biography of an element.

There are 3 stages people usually talk about:

  • Mounted — this is when an element is created in the UI
  • Updating — when state or props change and the component rerenders
  • Unmounted — when an element is deleted or removed from the UI

Now let us talk about some hook rules.

They must be top-level — you cannot call them inside a loop or a conditional.

They must be in a function — hooks must be used inside a React component or a custom hook.

Now let us talk about the hooks themselves.

useState

What is useState? This is a hook designed to create state that can be updated over time.

But what is state? State is data that belongs to a component at a point in time.

This is an example of how it is used in a component:

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

When you use useState, you create a variable and an updater function.

When do you use state?

It is mostly used when the state has to update the component. Like the popular counter example, it updates the number on the page when the button is pressed, which tells React that it needs to rerender the component.

This is the counter example in code:

import { useState } from "react";

const Counter = () => { const [count, setCount] = useState(0); return (

{count}

<button onClick={() => { setCount(count + 1); }} > increase count by one
); };

useReducer

This is similar to useState, but it is often used when multiple state values have to be updated together or when state logic gets more complex.

Here is an example of how it is created:

const [state, dispatch] = useReducer(reducer, initialState);

How it is used:

const initialState = { count: 0 };

function reducer(state, action) { switch (action.type) { case "increment": return { count: state.count + 1 }; default: return state; } }

export function Counter() { const [state, dispatch] = useReducer(reducer, initialState); return ( <button onClick={() => dispatch({ type: "increment" })}> {state.count} ); }

useContext

Before I talk about useContext, what is context? Context is a way to get information to components without prop drilling.

But what is prop drilling? Just imagine your app as a tree. The root is App.jsx, and you have a button that is 5 components deep. If you want to get data from App.jsx all the way down there by passing props through every layer, that is prop drilling.

Context lets you create shared data and then use useContext to read that data where you need it. I will talk more about context later.

useRef

useRef returns a mutable ref object whose .current property persists for the full lifetime of the component. It is commonly used for accessing DOM elements directly or storing values that should survive rerenders without causing a rerender.

const inputEl = useRef(null);

// Access via inputEl.current

When to use useRef:

  • interacting with DOM elements
  • focusing search on page load
  • forcing a chat window to the bottom when a new message comes in
  • storing things like timers or previous values

When not to use useRef:

  • as a replacement for state when the UI needs to update
  • for changing props
  • for rendering data directly that should trigger rerenders

useEffect

I just want to say that useEffect is one of the main reasons I went out of my way to write this blog post, because I want to tell you this: do not overuse effects.

useEffect runs code after a component renders and commits to the screen.

This is an example of a useEffect:

useEffect(() => {
  // Setup logic
  return () => {
    /* Cleanup logic */
  };
}, [dependencies]);

As you can see at the end of the useEffect call, the dependency array tells React when to rerun the effect.

When do you need an effect

  • for non-React systems or external systems
  • manual DOM manipulation
  • analytics
  • timers
  • subscriptions and event listeners

When you do not need an effect

  • deriving data from props or state
  • data transformation, where useMemo may help
  • user event handling
  • resetting something directly during render when possible
  • syncing with an external store, where useSyncExternalStore can help

And please, in the good name of React, do not do this unless you really know why:

import { useEffect, useState } from "react";

function UserProfile({ userId }) { const [user, setUser] = useState(null);

useEffect(() => { let ignore = false; fetch(/api/users/${userId}) .then((res) => res.json()) .then((data) => { if (!ignore) setUser(data); }); return () => { ignore = true; }; }, [userId]);

return

{user?.name}
; }

This pattern can work, but it has a lot of flaws. You have to handle loading, errors, race conditions, retries, caching, and refetching yourself. That is why tools like TanStack Query are often better for client-side data fetching.

But there is something similar to useEffect. It is called useLayoutEffect, and it runs synchronously after DOM mutations but before the browser paints. It is useful when you need to measure layout or avoid flicker, but do not use it unless you actually need it.

useMemo

This caches the result of an expensive calculation between renders.

const cachedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

As you can see, it takes a function and a dependency array to recalculate the value when needed.

Just do not use useMemo everywhere. It is not free, and if the calculation is cheap, it can make the code harder to read for no real gain.

useCallback

useCallback is used to cache a function between renders. It is usually used to help avoid unnecessary rerenders in memoized child components or to keep function references stable for dependencies.

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

Just like useMemo, do not use useCallback everywhere either. Use it when it solves a real problem, not because it looks advanced.

When to use useMemo vs useCallback

useMemo is for when you want to cache the final value.

useCallback is for when you want to cache the function.

useSyncExternalStore

So basically, why do we use this and not useEffect? useSyncExternalStore is designed for subscribing to external stores in a way that works correctly with concurrent rendering. It helps React read consistent data and avoid UI tearing.

What is Suspense

Suspense gives your React app the ability to wait before showing part of the UI.

How does it work

Basically, when handling async work or lazy-loaded components, Suspense lets React show a fallback while something is still loading. That fallback can be another component until the other thing is ready.

What are the benefits

  • You do not have to add an isLoading variable everywhere for supported async UI patterns
  • You can coordinate loading states better
  • You can wrap multiple elements in a Suspense boundary

But something that often comes with Suspense is lazy.

What is lazy

Lazy loading is when you only load the JavaScript for a component when it is needed. Combined with Suspense, it can make the app feel better.

Here is an example:

import { Suspense, lazy } from "react";

const HeavyComponent = lazy(() => import("./HeavyComponent"));

function App() { return ( <Suspense fallback={

Loading component...

}> ); }

We first lazy-load the heavy component and then wrap it in Suspense. Then we tell Suspense what its fallback is. But the fallback can be any React component, so if you want to use a custom loader, feel free.

What are error boundaries

This is used when a component crashes or throws an error instead of rendering correctly.

import { ErrorBoundary } from "react-error-boundary";

function ErrorFallback({ error, resetErrorBoundary }) { return (

Something went wrong:

{error.message}
); }

<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { /* Reset state here */ }}

;

This is not the built-in class-based way, but I am using this because I do not recommend teaching class components here, and this package is easier to show. For this method to work, you need to install react-error-boundary.

Also, note that error boundaries catch rendering errors in the component tree below them. They do not catch every async error or every event handler error.

What is Context

Context is a built-in API in React that allows you to pass data around your app without prop drilling. Just know that context does not manage state by itself — it distributes state.

So if you have data like auth info, theme preference, locale, or things like that which do not change constantly, context can be a good fit. But if the context value changes a lot, it can cause more rerenders for consumers.

If you have state that changes often or has more complex update patterns, you may want a state management library like Zustand or Redux. But just remember to use what works for you.

How to create it in 3 steps:

1. Initialize the context

This acts like the bucket that transfers data around the app.

import { createContext, useContext, useState } from "react";

const ThemeContext = createContext(undefined);

2. Create the provider

The provider is what broadcasts the state through the component tree.

import { createContext, useContext, useState } from "react";

const ThemeContext = createContext(undefined);

export function ThemeProvider({ children }) { const [theme, setTheme] = useState("light"); const toggleTheme = () => { setTheme((prev) => (prev === "light" ? "dark" : "light")); }; return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> ); }

3. Consume the context

With the useContext hook I talked about earlier:

import { useContext } from "react";

export function useTheme() { const context = useContext(ThemeContext); if (!context) { throw new Error("useTheme must be used within a ThemeProvider"); } return context; }

function ThemeToggleButton() { const { theme, toggleTheme } = useTheme(); return ( ); }

Custom Hooks

Now let us talk about a superpower React developers have: the ability to write custom hooks.

Imagine if you had component logic that you wanted to reuse. That is what custom hooks are. They are standalone logic units that can be shared among components.

A rule for custom hooks is that they must start with use.

For example, here is one that lets you get auth state:

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

Now you just go into your component and say:

import { useAuth } from "../useAuth";

export const UserName = () => { const auth = useAuth(); if (!auth.user) { return

Loading...
; } return
{auth.user.name}
; };

And that is it. You can use this all over your app.

React Compiler

React Compiler, originally named React Forget, shifts some of the burden of writing manual optimizations to the build process.

This is not a normal hook or runtime feature, but it is worth talking about when discussing useMemo and useCallback, because normally you handle some of that manually. The compiler can optimize some cases automatically and reduce unnecessary rerenders.

React Query or TanStack Query

TanStack Query is a powerful asynchronous state management library that specializes in keeping server state in sync with the client UI. When data is fetched with this library, it handles caching and helps React update the UI.

This is one of the main libraries people recommend instead of hand-writing a lot of useEffect-based fetch logic.

Why use it instead of writing custom hooks? Because it gives you caching, retries, deduplication, invalidation, and more without you having to build all of that yourself.

Core concepts

TanStack Query has 3 core concepts:

  • Query — fetching data
  • Mutation — changing data
  • Query invalidation — marking data as stale so it can refetch

How to set it up

1. Create a Query Client

import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: 1, }, }, });

2. Wrap your app with QueryClientProvider

import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./lib/query";

function App() { return ( ); }

3. Create your query or mutation

import {
  useMutation,
  useQuery,
  useQueryClient,
} from "@tanstack/react-query";

export function TodoManager() { const queryClient = useQueryClient();

const { data: todos, isLoading, error, } = useQuery({ queryKey: ["todos"], queryFn: async () => { const response = await fetch("/api/todos"); if (!response.ok) throw new Error("Network error"); return response.json(); }, });

const addTodoMutation = useMutation({ mutationFn: async (newTitle) => { const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify({ title: newTitle }), }); return response.json(); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["todos"] }); }, });

if (isLoading) return

Loading todos...

; if (error) return

Error: {error.message}

;

return (

My Tasks

<button disabled={addTodoMutation.isPending} onClick={() => addTodoMutation.mutate("New Task from UI")} className="bg-blue-500 rounded p-2 text-white" > {addTodoMutation.isPending ? "Adding..." : "Add Quick Task"}
    {todos?.map((todo) => ( <li key={todo.id} className={todo.completed ? "line-through" : ""} > {todo.title} ))}
); }

Optimistic UI

This is a frontend design strategy that updates the UI before checking whether the server update succeeded. The UI updates immediately.

So how do we do optimistic UI in React?

By using a hook I did not talk about until now called useOptimistic.

The hook takes in your server state and a reducer-like function to calculate the new state while the action is pending.

const [optimisticState, addOptimistic] = useOptimistic(
  passthroughState,
  (currentState, optimisticValue) => {
    return { ...currentState, ...optimisticValue };
  }
);

Implementation example:

type Success = { data: T; error: null };
type Failure = { data: null; error: E };
type Result = Success | Failure;

export async function tryCatch(promise) { try { const data = await promise; return { data, error: null }; } catch (error) { return { data: null, error }; } }

The component logic:

import { useOptimistic } from "react";
import { updateLikeStatus } from "./actions";

function LikeButton({ post }) { const [optimisticPost, addOptimisticLike] = useOptimistic( post, (state, newLikeCount) => ({ ...state, likes: newLikeCount, hasLiked: !state.hasLiked, }) );

async function handleLike() { const nextLikes = optimisticPost.hasLiked ? optimisticPost.likes - 1 : optimisticPost.likes + 1; addOptimisticLike(nextLikes);

const { error } = await tryCatch(updateLikeStatus(post.id));
if (error) {
  // handle error here if you want
  // UI will fall back when server state updates
}

}

return ( ); }

So the code above shows how you can use useOptimistic to update a like button.

Direct DOM Manipulation

In React, you can do direct DOM manipulation, but only in client components. If it is a server component, there is no browser, so there is no DOM to access.

For client components, direct DOM manipulation is useful when accessing browser APIs like canvas, video, focus management, or when using a non-React library that needs DOM access.

How to access the DOM:

You use useRef.

import { useRef } from "react";

function FocusInput() { const inputRef = useRef(null);

const handleClick = () => { inputRef.current?.focus(); };

return (

); }

You can also use browser APIs in useEffect so they run in the browser and not during server rendering:

useEffect(() => {
  const handleScroll = () => console.log(window.scrollY);
  window.addEventListener("scroll", handleScroll);
  return () => window.removeEventListener("scroll", handleScroll);
}, []);

Portal

A React portal is a way to render a component outside part of the normal DOM hierarchy.

Normally, React components are mounted where they appear in the tree, but sometimes that means parent CSS or overflow behavior affects the child. Portals help with things like modals and tooltips.

Here is an example:

import { createPortal } from "react-dom";

function MyPortalComponent() { return (

This child is placed in the parent div.

{createPortal(

This child is placed in the document body!

, document.body )}
); }

Best practices:

  • make sure the target node exists
  • if you create the node dynamically, clean it up in useEffect

Conclusion

I hope this was helpful on your journey to learning React, and if you have any questions, DM me on X at @JemoLife0213.

And I just want to say that my framework of choice is TanStack Start because of the type-safe router.


© 2026 Ifeanyichukwu Jeremy Nwachukwu // Tactical Terminal