DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning React As If You Built It Yourself

If you have ever built a webpage with vanilla JS or jQuery, you remember the dance. Click a button, find the element, change its text, also flip a class on its sibling, also hide that other thing over there, oh and update the counter in the header, do not forget the counter. Now multiply that by twenty interactions on the page, and you have a small horror movie.

The bug is almost always the same: the DOM said one thing, your variables said another, and they slowly drifted apart. You spent your evening pushing pixels around by hand.

That is the gap React fills.

What is React, really

Think of React as a smart artist who paints whatever you describe. You hand them a sentence: "a happy cat with a hat, named Mochi, age 2". They paint exactly that. Later, when Mochi turns 3, you do not erase and repaint the whole canvas. You just hand the artist a new sentence, and the artist quietly changes only what is different. The hat stays. The smile stays. The age becomes 3.

You are no longer the painter. You are the writer. You describe what the screen should look like for a given state. React figures out how to make the real DOM match. That is the whole magic.

This style has a name: declarative UI. You declare the destination, React handles the path.

Let's pretend we are building one

We want a way to build interactive UIs without spending our lives synchronizing variables with the DOM. We will call it React. Every decision we make will be something you see in real React code, so let's make them on purpose.

For our running example, we are going to build a tiny Cat Adoption Wall, growing it piece by piece.

Decision 1: UI is built from small reusable pieces

We will call them components. A component is just a function that takes some inputs and returns a description of what to draw. Pure JavaScript, nothing magical.

function CatCard() {
  return (
    <div className="card">
      <h2>Mochi</h2>
      <p>Looking for a cozy lap.</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That HTML looking thing inside the function is not HTML. It is JSX, which is just a friendlier way of writing React.createElement(...). A build tool (Vite, Next.js, whatever) turns it into real JavaScript before it ships.

Two rules of JSX that bite newcomers:

  • Use className instead of class, and htmlFor instead of for. Reason: class and for are reserved words in JS.
  • Every element you return must be wrapped in a single parent. If you do not want an extra <div>, use the empty fragment tags: <>...</>.

Components compose. A bigger component just calls smaller ones:

function AdoptionWall() {
  return (
    <main>
      <h1>Cats looking for a home</h1>
      <CatCard />
      <CatCard />
      <CatCard />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Three identical cats. That is not very useful yet. Let's make them different.

Decision 2: Pass data in with props

A component should be reusable, so we let callers pass values in. We will call them props (short for "properties"). They are just function arguments.

function CatCard({ name, bio, age }) {
  return (
    <div className="card">
      <h2>{name}</h2>
      <p>{bio}</p>
      <small>{age} years old</small>
    </div>
  );
}

function AdoptionWall() {
  return (
    <main>
      <h1>Cats looking for a home</h1>
      <CatCard name="Mochi"   bio="Looking for a cozy lap." age={2} />
      <CatCard name="Whiskers" bio="Loves boxes."           age={4} />
      <CatCard name="Pepper"   bio="Will steal your snack." age={1} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Curly braces {} inside JSX mean "pop out into JavaScript for a moment". Anything that returns a value works inside there: variables, function calls, ternaries, even other JSX.

Important rule: props flow down, not up. A parent gives data to a child. A child should not reach up and change the parent's data directly. We will see how data flows back up in a moment.

Decision 3: When data changes, the screen should change too

A static page is not a UI. We need something that, when it changes, makes React repaint the relevant parts. We will call it state, and the way you ask for some is the useState hook.

import { useState } from "react";

function LikeButton() {
  const [likes, setLikes] = useState(0);

  return (
    <button onClick={() => setLikes(likes + 1)}>
      Mochi has {likes} likes
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Read that first line out loud:

"Give me a piece of state called likes, starting at 0, and a setter to change it."

The trick: every time you call setLikes, React schedules a re-render. It calls your component function again. The function returns a fresh description of the UI based on the new value. React diffs the new description against the previous one and only touches the parts that actually changed in the real DOM.

You did not move any pixels. You did not query any elements. You changed a variable, and the screen followed.

A few things worth burning into memory, because they trip up everyone:

  • State is per component instance. Two LikeButtons on the page have two independent counters.
  • Updates are not instant. setLikes(likes + 1) schedules an update. Right after that line, likes is still the old value in the current function call.
  • For updates that depend on the current value, use the function form: setLikes((prev) => prev + 1). This is safer when multiple updates queue up.
  • Treat state as immutable. Do not mutate arrays or objects in place. Replace them with new ones: setCats([...cats, newCat]), not cats.push(newCat). React decides whether to re-render by comparing references, so a mutated array looks identical and nothing happens.

That last rule is the source of about half of all "why is my UI not updating" bugs.

Decision 4: Lists need a stable identity

When we render an array of things, React needs to tell them apart between renders, so it knows which item moved, which got deleted, and which is new. We give every item a key:

function AdoptionWall({ cats }) {
  return (
    <main>
      <h1>Cats looking for a home</h1>
      {cats.map((cat) => (
        <CatCard key={cat.id} {...cat} />
      ))}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

The key should be something stable and unique inside that list. A database id is perfect. The array index is not good enough as soon as items can be reordered or deleted, because the index of an item changes when its neighbors change. Wrong keys lead to weird bugs where typed text jumps to a different row, or animations apply to the wrong item.

Decision 5: Children talk back through callbacks

Props go down. To send something back up, the parent passes a function down, and the child calls it.

function CatCard({ cat, onAdopt }) {
  return (
    <div className="card">
      <h2>{cat.name}</h2>
      <button onClick={() => onAdopt(cat.id)}>Adopt</button>
    </div>
  );
}

function AdoptionWall() {
  const [cats, setCats] = useState(initialCats);

  function handleAdopt(id) {
    setCats((prev) => prev.filter((c) => c.id !== id));
  }

  return (
    <main>
      {cats.map((cat) => (
        <CatCard key={cat.id} cat={cat} onAdopt={handleAdopt} />
      ))}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

The cat list lives in AdoptionWall. The button lives in CatCard. They communicate through onAdopt. This pattern is called lifting state up: when two components need to share a piece of state, you move it up to their nearest common ancestor.

If lifting starts to feel painful (you are passing the same prop through five layers), that is your hint to reach for one of the tools below. Not before.

Decision 6: Sometimes you need to talk to the outside world

State is for things React owns. But the outside world exists too: timers, websockets, the document title, browser APIs, third party widgets. We need an escape hatch to sync with those, and we call it useEffect.

import { useEffect } from "react";

function PageTitle({ count }) {
  useEffect(() => {
    document.title = `${count} cats waiting`;
  }, [count]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Two things to know:

  • The function inside runs after React updates the DOM.
  • The array at the end is the dependency list. React reruns the effect only when one of those values changes.

Modern advice (this is important): useEffect is for synchronizing with external systems, not for "do this thing when the user clicks". For that, just use the click handler. A good test: if removing your effect would not silently leak something or fall out of sync with the outside world, it probably should not be an effect.

The most common misuse is fetching data inside useEffect. It works, but it is a road full of footguns (race conditions, cache invalidation, double fetches in dev, no caching). The much better path lives in Decision 9.

Decision 7: Forms, the friendly version

Forms used to be where everyone gets stuck. React 19 made them dramatically nicer with Actions and the useActionState hook. You hand the form an async function, and React handles the pending state, the result, and the form reset for you.

import { useActionState } from "react";

async function adoptCat(prev, formData) {
  const name = formData.get("name");
  const res  = await fetch("/api/adopt", { method: "POST", body: formData });
  if (!res.ok) return { error: "Could not adopt" };
  return { ok: true, message: `${name} is going home` };
}

function AdoptForm() {
  const [state, formAction, pending] = useActionState(adoptCat, null);

  return (
    <form action={formAction}>
      <input name="name" placeholder="Cat name" />
      <button type="submit" disabled={pending}>
        {pending ? "Sending..." : "Adopt"}
      </button>
      {state?.error   && <p style={{ color: "red"   }}>{state.error}</p>}
      {state?.message && <p style={{ color: "green" }}>{state.message}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

That single hook replaces the classic "three useStates plus a try/catch plus a loading flag" pattern. The companion hook useFormStatus() works inside any descendant of the form and tells you pending, data, method, action. No prop drilling needed for a submit button to know its form is busy.

For richer forms with lots of fields and validation rules, reach for React Hook Form. It is the de facto choice in 2026. It avoids unnecessary re-renders and pairs nicely with Zod for type safe validation.

Decision 8: Show users instant feedback with useOptimistic

When the user clicks "Adopt", we do not want to make them stare at a spinner for 400ms. We want the cat to disappear from the wall instantly, and only roll back if the server says no.

React 19 ships a hook for exactly that:

import { useOptimistic } from "react";

function AdoptionWall({ cats }) {
  const [optimisticCats, removeOptimistic] = useOptimistic(
    cats,
    (current, idToRemove) => current.filter((c) => c.id !== idToRemove),
  );

  async function handleAdopt(id) {
    removeOptimistic(id);          // UI updates right now
    await fetch(`/api/adopt/${id}`, { method: "POST" });
    // when the real cats list refreshes, the optimistic state goes away
  }

  return optimisticCats.map((cat) => (
    <CatCard key={cat.id} cat={cat} onAdopt={handleAdopt} />
  ));
}
Enter fullscreen mode Exit fullscreen mode

If the request fails, React swaps back to the real list automatically. You write the happy path, the framework handles the rollback.

Decision 9: State management, told straight

Here is the truth that the React ecosystem learned the hard way and finally agreed on:

Server state and client state are different problems. Stop solving them with the same tool.

Server state is data that lives somewhere else (a database, an API). It needs caching, refetching, retries, deduping, and invalidation. Client state is just stuff your UI cares about: which tab is open, is the sidebar collapsed, what theme are we in.

The 2026 default playbook looks like this:

Kind of state Tool Why
Local component state useState / useReducer It does not need to leave the component.
Form state React Hook Form (+ Zod) Fewer re-renders, great validation story.
Server data TanStack Query (React Query) Caching, refetching, loading flags, all for free.
Global UI state Zustand Tiny store, one hook to read and write, no boilerplate.
Atomic / fine grained Jotai When many small bits of state should react independently.
Big enterprise app Redux Toolkit Strict patterns, devtools, time travel debugging.

Here is the trap to avoid: do not fetch data with fetch inside useEffect and shove the result into Zustand or Redux. That is the most common anti pattern in 2026. You end up reinventing caching, you forget to invalidate, you fight stale data. Use TanStack Query for anything that came from a server.

A taste of each.

TanStack Query for server data

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

function AdoptionWall() {
  const { data: cats, isLoading, error } = useQuery({
    queryKey: ["cats"],
    queryFn:  () => fetch("/api/cats").then((r) => r.json()),
  });

  if (isLoading) return <p>Loading...</p>;
  if (error)     return <p>Something went wrong</p>;

  return cats.map((cat) => <CatCard key={cat.id} cat={cat} />);
}
Enter fullscreen mode Exit fullscreen mode

Caching, retry, background refetch, dedupe across components, all included.

Zustand for global UI state

// stores/ui.js
import { create } from "zustand";

export const useUIStore = create((set) => ({
  theme: "light",
  toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
}));
Enter fullscreen mode Exit fullscreen mode
function ThemeToggle() {
  const theme       = useUIStore((s) => s.theme);
  const toggleTheme = useUIStore((s) => s.toggleTheme);
  return <button onClick={toggleTheme}>Theme: {theme}</button>;
}
Enter fullscreen mode Exit fullscreen mode

No provider to wrap your app in, no reducers, no actions, no boilerplate. One file, one hook, done.

Context for very small global stuff

Built into React, free. Best for things that rarely change: theme, current user, locale. Do not use Context as a state manager for fast changing state, because every consumer re-renders when the value changes. Use Zustand instead and you get cheap selector subscriptions.

const ThemeContext = createContext("light");

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Page />
    </ThemeContext.Provider>
  );
}

function Page() {
  const theme = useContext(ThemeContext);
  return <p>Current theme: {theme}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Decision 10: Move work to the server when you can

For the longest time, every React app shipped a giant bundle of JavaScript, then asked the user's browser to do everything: fetch data, render markup, hydrate, run interactivity. That is a lot of work to do in the laggy place.

React 19 leaned into a different idea: Server Components and Server Actions. A server component runs on the server, can read from a database directly, and ships only the rendered output to the browser. A server action is just an async function marked "use server" that the client can call like a local function, but it actually runs on the server. No API route needed.

// app/cats/page.jsx, a Server Component (Next.js style)
import { db } from "@/lib/db";
import AdoptButton from "./AdoptButton";

export default async function CatsPage() {
  const cats = await db.cat.findMany();
  return cats.map((cat) => (
    <div key={cat.id}>
      <h2>{cat.name}</h2>
      <AdoptButton id={cat.id} />
    </div>
  ));
}
Enter fullscreen mode Exit fullscreen mode
// AdoptButton.jsx, a Client Component
"use client";

import { adoptCat } from "./actions";

export default function AdoptButton({ id }) {
  return <button onClick={() => adoptCat(id)}>Adopt</button>;
}
Enter fullscreen mode Exit fullscreen mode
// actions.js
"use server";

import { db } from "@/lib/db";

export async function adoptCat(id) {
  await db.cat.update({ where: { id }, data: { adopted: true } });
}
Enter fullscreen mode Exit fullscreen mode

The mental model:

  • Default to Server Components. They are cheaper, they keep secrets safe, they do not send code to the browser.
  • Add "use client" only on the small islands that actually need interactivity (a button, a form, a dropdown).
  • For mutations, write a function with "use server" and call it. No API route to hand roll.

You do not get Server Components in a plain Vite SPA. You get them in a framework like Next.js (App Router) or React Router 7 (Framework Mode). For a classic single page app, Vite + React + TanStack Query + Zustand is still excellent and very common in 2026.

Decision 11: A few more goodies in React 19

  • use() lets you read a Promise (or a Context) inside a component and have React suspend until it resolves. Pairs beautifully with Suspense and Server Components.
  • useTransition marks an update as "not urgent". The UI stays responsive while the heavy update happens in the background, and you get a pending flag for free. In React 19, the function passed to it can be async.
  • ref is now just a prop. No more forwardRef wrapping. You take ref like any other prop.
  • <title>, <meta>, <link> work anywhere. React hoists them into the document head for you, so SEO and head tags no longer need a separate library.
  • Better error messages for hydration mismatches, plus an automatically wired Document Metadata system.

You do not need any of this on day one. Knowing it exists means you will recognize it when you see it.

A peek under the hood

What really happens when you click a button in a React app:

  1. Your onClick handler runs and calls setSomething(newValue).
  2. React marks that component (and the tree below it) as "needs to re-render".
  3. React calls your component function again. It gets back a new tree of JSX, in memory only. This in memory tree is sometimes called the virtual DOM.
  4. React compares the new tree to the previous one. This is reconciliation. It figures out the smallest set of real DOM changes that would make reality match the new tree.
  5. It applies only those changes to the real DOM.
  6. After the DOM is committed, any useEffect whose dependencies changed runs.

That is why React feels fast even when you "render the whole thing on every state change". You do not actually rebuild the DOM, you rebuild a cheap description of it, and only the diff hits the real page.

Tiny tips that will save you later

  • Start with useState. Add libraries when you actually feel the pain.
  • Never store server data in client state. Use TanStack Query.
  • Treat state as immutable. No .push, no obj.x = y. Always make a new value.
  • Use the function form of setters when the next value depends on the previous: setCount((c) => c + 1).
  • Pick stable keys for lists. The array index is a trap as soon as items can move.
  • Effects sync with the outside world. They are not a "do something on click" tool.
  • Lift state to the lowest common parent that needs it, not to the top of the app.
  • Reach for Server Components and Server Actions when you can. Less JS shipped to the user, and your secrets stay on the server.
  • Default form library: React Hook Form + Zod. Default data fetcher: TanStack Query. Default global UI store: Zustand. You can build 95% of an app with that trio.
  • Use Vite for SPAs and Next.js (or React Router 7) for full apps. Avoid create-react-app, it has been retired.

Wrapping up

So that is the whole story. We were tired of pushing pixels around by hand. We built a framework where you describe what the UI should be for a given state, and the framework handles how to make the screen match. We broke the UI into small functions called components. We let them take props down and call functions up. We added state to make things change, effects to talk to the outside world, and a tidy little pipeline of modern hooks (useActionState, useOptimistic, useTransition, use) to handle async work without pain.

Then we admitted that not every piece of state is the same:

  • Local stuff stays in useState.
  • Forms go to React Hook Form.
  • Anything from a server goes to TanStack Query.
  • Shared UI state goes to Zustand.
  • Server work goes to Server Components and Server Actions.

Once that map is in your head, every React tutorial, blog post, and codebase starts to feel familiar. You stop fighting the framework and start moving at speed.

Happy rendering, and give your virtual cat a virtual scratch from me.

Top comments (0)