Home JavaScript React.js Explained: Components, State & the Virtual DOM

React.js Explained: Components, State & the Virtual DOM

In Plain English 🔥
Imagine you're building a LEGO city. Instead of sculpting the whole city from one block of clay — which means restarting whenever you want to move a building — you build it from individual LEGO bricks that snap together. Each brick knows exactly what it looks like. React is the same idea: you build your UI from small, reusable 'component' bricks, and when one brick changes, only that brick gets rebuilt — not the whole city.
⚡ Quick Answer
Imagine you're building a LEGO city. Instead of sculpting the whole city from one block of clay — which means restarting whenever you want to move a building — you build it from individual LEGO bricks that snap together. Each brick knows exactly what it looks like. React is the same idea: you build your UI from small, reusable 'component' bricks, and when one brick changes, only that brick gets rebuilt — not the whole city.

Every time you 'like' a post on Instagram, see a live search result drop down, or watch a shopping cart update without the whole page reloading — you're watching React do its job. React is a JavaScript library built by Meta in 2013 that fundamentally changed how developers think about building user interfaces. It's now used by Facebook, Airbnb, Netflix, and thousands of other production apps because it makes complex UIs manageable without turning your codebase into a tangled mess of DOM manipulations.

Why React Exists — The Problem With Vanilla DOM Manipulation

Before React, building a dynamic UI meant manually querying the DOM and surgically updating elements. This works fine for a simple counter, but it falls apart fast. Imagine a social feed: a new like comes in, which changes the like count, which might affect whether the 'Popular' badge shows, which might reorder the feed. Now you're tracking every dependency by hand, syncing state between a dozen DOM nodes, and praying nothing gets out of sync.

React's core insight is this: instead of describing HOW to change the UI step by step, you describe WHAT the UI should look like for any given state, and let React figure out the minimal set of DOM changes needed. It's declarative rather than imperative — you write the 'end result', not the 'recipe'.

This mental shift is the real reason React matters. Your code describes intent, not procedure. That makes it dramatically easier to reason about, test, and maintain — especially in a team where multiple people are touching the same UI.

VanillaVsReact.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// ─── VANILLA JS APPROACH ───────────────────────────────────────────
// Manually track and update every affected DOM node when data changes.
// This gets chaotic the moment your UI has more than a handful of states.

let likeCount = 0;

function handleLikeClick() {
  likeCount += 1;

  // Manually update the counter label
  document.getElementById('like-count').textContent = likeCount;

  // Manually update the button colour based on whether we've liked it
  const likeButton = document.getElementById('like-btn');
  likeButton.style.color = likeCount > 0 ? 'blue' : 'grey';

  // Manually show a 'Popular' badge once it crosses a threshold
  const badge = document.getElementById('popular-badge');
  badge.style.display = likeCount >= 10 ? 'block' : 'none';

  // ... and it keeps growing. Every new rule = more manual syncing.
}


// ─── REACT APPROACH ────────────────────────────────────────────────
// Describe WHAT the UI should look like for any given likeCount.
// React calculates the minimum DOM changes needed — you never touch the DOM.

import React, { useState } from 'react';

function LikeButton() {
  // likeCount is our single source of truth. React re-renders this
  // component whenever likeCount changes — we don't manually update anything.
  const [likeCount, setLikeCount] = useState(0);

  const hasLiked = likeCount > 0;
  const isPopular = likeCount >= 10;

  return (
    <div>
      {/* The UI is a pure function of likeCount — no manual DOM calls */}
      <button
        onClick={() => setLikeCount(likeCount + 1)}
        style={{ color: hasLiked ? 'blue' : 'grey' }}
        id="like-btn"
      >
        👍 {likeCount}
      </button>

      {/* Conditional rendering: React handles show/hide based on state */}
      {isPopular && <span id="popular-badge">🔥 Popular</span>}
    </div>
  );
}

export default LikeButton;
▶ Output
// Rendered output in the browser when likeCount = 11:
// [👍 11] 🔥 Popular
//
// The button text is blue, the badge is visible.
// Zero manual DOM queries — React derived everything from likeCount.
🔥
The Core Mental Model:Think of a React component as a pure function: UI = f(state). Given the same state, it always produces the same UI. This predictability is why React components are so easy to test and debug — you just check what renders for a given set of inputs.

Components and Props — Building Real UI From Reusable Pieces

A React component is just a JavaScript function that returns JSX — a syntax that looks like HTML but is actually JavaScript under the hood. The power isn't in the syntax though. It's in composability: components can contain other components, and data flows down through props (short for properties) like water flowing downhill.

Props are how a parent component communicates with a child. They're read-only from the child's perspective — a child component never modifies its own props. This one-way data flow is intentional. It makes debugging dramatically easier because you always know where data originates.

The real-world pattern you'll use constantly is the 'smart parent / dumb child' split. A parent component fetches data and holds state. It passes that data down as props to presentational child components that just render what they receive. This separation keeps your logic in one place and your UI components reusable across the whole app.

ProductCard.jsx · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// ─── ProductCard.jsx ────────────────────────────────────────────────
// A reusable 'dumb' component — it receives product data as props
// and renders it. It has no idea where the data came from.

import React from 'react';

// Destructure props directly in the parameter list for clarity.
// This component will re-render whenever its props change.
function ProductCard({ name, price, imageUrl, isOnSale }) {
  return (
    <div className="product-card">
      <img src={imageUrl} alt={name} />

      <h3>{name}</h3>

      <div className="price-block">
        {/* Conditional: show a sale badge only when isOnSale is true */}
        {isOnSale && <span className="sale-badge">SALE</span>}

        {/* Template literal makes the price format explicit */}
        <p className="price">${price.toFixed(2)}</p>
      </div>
    </div>
  );
}


// ─── ProductList.jsx ─────────────────────────────────────────────────
// The 'smart parent' — it owns the data and maps it into ProductCards.
// ProductCard doesn't care how many products there are or where they come from.

import React from 'react';
import ProductCard from './ProductCard';

function ProductList() {
  // In a real app this data would come from an API via useEffect/fetch.
  // For now, hardcoded to keep the focus on component composition.
  const products = [
    {
      id: 1,
      name: 'Mechanical Keyboard',
      price: 129.99,
      imageUrl: '/images/keyboard.jpg',
      isOnSale: false,
    },
    {
      id: 2,
      name: 'Ergonomic Mouse',
      price: 49.99,
      imageUrl: '/images/mouse.jpg',
      isOnSale: true,
    },
    {
      id: 3,
      name: 'USB-C Hub',
      price: 34.99,
      imageUrl: '/images/hub.jpg',
      isOnSale: true,
    },
  ];

  return (
    <section className="product-list">
      <h2>Our Products</h2>

      {/* Map over the array — each item becomes a ProductCard */}
      {/* The 'key' prop is mandatory here. React uses it to track */}
      {/* which items changed, added, or removed in the list. */}
      {products.map((product) => (
        <ProductCard
          key={product.id}
          name={product.name}
          price={product.price}
          imageUrl={product.imageUrl}
          isOnSale={product.isOnSale}
        />
      ))}
    </section>
  );
}

export default ProductList;
▶ Output
// Rendered HTML structure in the browser:
//
// <section class="product-list">
// <h2>Our Products</h2>
// <div class="product-card"> <!-- Mechanical Keyboard, no badge -->
// <div class="product-card"> <!-- Ergonomic Mouse, SALE badge -->
// <div class="product-card"> <!-- USB-C Hub, SALE badge -->
// </section>
//
// ProductCard is used 3 times but defined once. Change the component
// definition once and all three cards update automatically.
⚠️
Watch Out: Using array index as keyNever use the array index as a `key` prop in lists that can reorder or filter — e.g., key={index}. React uses keys to identify which DOM nodes to reuse. If item order changes, React will match the wrong nodes and produce subtle UI bugs like inputs keeping the wrong values. Always use a stable, unique ID from your data.

State and the Virtual DOM — How React Knows What to Re-render

State is data that, when it changes, should cause the UI to update. That's the full definition. React gives you useState to manage local component state, and the rules are simple: never mutate state directly — always call the setter function. This isn't bureaucracy; it's how React detects that something changed.

When you call a state setter, React doesn't immediately blast the real DOM with updates. Instead it re-runs your component function to produce a new virtual DOM — a lightweight JavaScript object tree describing what the UI should look like. React then diffs the new virtual DOM against the previous one (this is called 'reconciliation'), finds the minimum set of actual DOM changes, and applies only those. This is why React is fast even for complex UIs.

Understanding this flow — state change → re-render → virtual DOM diff → minimal real DOM update — explains most of React's behaviour, including why state updates can feel 'async' and why you should keep expensive calculations out of the render path.

ShoppingCart.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
// ─── ShoppingCart.jsx ───────────────────────────────────────────────
// A practical example that shows: useState, state updates via setter,
// and derived state (calculating total from cart items).

import React, { useState } from 'react';

// Initial product catalogue — would normally come from an API.
const AVAILABLE_PRODUCTS = [
  { id: 101, name: 'React T-Shirt', price: 25 },
  { id: 102, name: 'JavaScript Mug', price: 12 },
  { id: 103, name: 'TypeScript Hoodie', price: 55 },
];

function ShoppingCart() {
  // cartItems holds an array of products the user has added.
  // We initialise it as empty — nobody has added anything yet.
  const [cartItems, setCartItems] = useState([]);

  function addToCart(product) {
    // NEVER do: cartItems.push(product) — this mutates state directly.
    // React won't detect the change and the UI won't update.
    //
    // CORRECT: create a NEW array with the spread operator.
    // React sees a new reference → triggers a re-render.
    setCartItems([...cartItems, product]);
  }

  function removeFromCart(productId) {
    // Filter returns a new array — again, no mutation.
    setCartItems(cartItems.filter((item) => item.id !== productId));
  }

  // Derived state: calculate the total from cartItems on every render.
  // Don't store total in its own useState — it's always derivable from cartItems.
  // Storing it separately would create two sources of truth that can drift apart.
  const cartTotal = cartItems.reduce((total, item) => total + item.price, 0);

  return (
    <div className="shopping-cart">
      {/* ── Product Catalogue ── */}
      <section className="catalogue">
        <h2>Catalogue</h2>
        {AVAILABLE_PRODUCTS.map((product) => (
          <div key={product.id} className="catalogue-item">
            <span>{product.name} — ${product.price}</span>
            <button onClick={() => addToCart(product)}>Add to Cart</button>
          </div>
        ))}
      </section>

      {/* ── Cart ── */}
      <section className="cart">
        <h2>Your Cart ({cartItems.length} items)</h2>

        {cartItems.length === 0 ? (
          // React renders this when the array is empty
          <p>Your cart is empty. Add something!</p>
        ) : (
          cartItems.map((item, index) => (
            // Using index as key here is acceptable ONLY because we remove
            // from the end and items don't reorder — see the callout below.
            // In production, use item.id (or a cart-entry UUID) instead.
            <div key={`${item.id}-${index}`} className="cart-item">
              <span>{item.name}</span>
              <span>${item.price}</span>
              <button onClick={() => removeFromCart(item.id)}>Remove</button>
            </div>
          ))
        )}

        {cartItems.length > 0 && (
          <div className="cart-total">
            <strong>Total: ${cartTotal}</strong>
          </div>
        )}
      </section>
    </div>
  );
}

export default ShoppingCart;
▶ Output
// After user clicks 'Add to Cart' on React T-Shirt and JavaScript Mug:
//
// Your Cart (2 items)
// ──────────────────────────────
// React T-Shirt $25 [Remove]
// JavaScript Mug $12 [Remove]
// ──────────────────────────────
// Total: $37
//
// After clicking Remove on React T-Shirt:
//
// Your Cart (1 item)
// ──────────────────────────────
// JavaScript Mug $12 [Remove]
// ──────────────────────────────
// Total: $12
⚠️
Pro Tip: Don't store derived data in stateIf a value can be calculated from existing state — like `cartTotal` from `cartItems` — calculate it during render instead of storing it in a second `useState`. Two states that must stay in sync will eventually drift apart, creating hard-to-reproduce bugs. React re-renders are fast enough that deriving values inline is almost never the performance bottleneck you think it is.

Fetching Real Data — useEffect and the Component Lifecycle

So far our data has been hardcoded. Real apps fetch data from APIs, and that's where useEffect comes in. The hook lets you synchronise your component with something outside React — a network request, a timer, a WebSocket, or a browser API.

The dependency array is the most misunderstood part of useEffect. It tells React WHEN to re-run the effect: empty array [] means run once after the first render (equivalent to 'on mount'); an array with values like [userId] means re-run whenever userId changes; no array at all means run after EVERY render (almost never what you want).

The cleanup function returned from useEffect is equally important and equally ignored by beginners. It runs before the component unmounts or before the effect re-runs. Without cleanup, you can end up with memory leaks, stale data from cancelled requests populating your state, or event listeners that stack up forever.

UserProfilePage.jsx · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// ─── UserProfilePage.jsx ────────────────────────────────────────────
// Real-world pattern: fetch user data when a userId changes,
// handle loading & error states, and clean up properly to avoid
// updating state on an unmounted component.

import React, { useState, useEffect } from 'react';

function UserProfilePage({ userId }) {
  const [userProfile, setUserProfile] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [fetchError, setFetchError] = useState(null);

  useEffect(() => {
    // AbortController lets us cancel the fetch if userId changes
    // before the previous request finishes — avoids race conditions.
    const abortController = new AbortController();

    // Reset state before each fresh fetch so stale data doesn't linger
    setIsLoading(true);
    setFetchError(null);
    setUserProfile(null);

    async function loadUserProfile() {
      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          { signal: abortController.signal } // tie the request to the controller
        );

        if (!response.ok) {
          throw new Error(`Server returned ${response.status}`);
        }

        const data = await response.json();
        setUserProfile(data);     // update state with the fetched profile
      } catch (error) {
        // AbortError is expected when we clean up — not a real error
        if (error.name !== 'AbortError') {
          setFetchError(error.message);
        }
      } finally {
        // Only mark loading as done if the request wasn't aborted
        if (!abortController.signal.aborted) {
          setIsLoading(false);
        }
      }
    }

    loadUserProfile();

    // Cleanup: cancel the in-flight request if userId changes or
    // the component unmounts before the fetch completes.
    return () => {
      abortController.abort();
    };

  }, [userId]); // Re-run this entire effect whenever userId changes


  // ── Render the right UI for each data-fetching state ──

  if (isLoading) {
    return <div className="skeleton-loader">Loading profile...</div>;
  }

  if (fetchError) {
    return (
      <div className="error-banner">
        <p>Could not load profile: {fetchError}</p>
        <p>Check your connection and try again.</p>
      </div>
    );
  }

  return (
    <div className="user-profile">
      <h1>{userProfile.name}</h1>
      <p>📧 {userProfile.email}</p>
      <p>🏢 {userProfile.company?.name}</p>
      <p>🌐 {userProfile.website}</p>
    </div>
  );
}

export default UserProfilePage;
▶ Output
// When userId = 1, component renders:
//
// [Loading profile...] ← shown while fetch is in-flight
//
// Then once data arrives:
//
// Leanne Graham
// 📧 Sincere@april.biz
// 🏢 Romaguera-Crona
// 🌐 hildegard.org
//
// If userId changes to 2 before the first fetch completes,
// the AbortController cancels it — no stale data flash.
⚠️
Watch Out: Missing cleanup causes ghost state updatesIf you fetch data inside useEffect without an AbortController and the user navigates away before the fetch completes, React will try to call setState on an unmounted component. In development this surfaces as a console warning: 'Can't perform a React state update on an unmounted component.' The fix is always the same — use AbortController and cancel the request in your cleanup function.
AspectVanilla JS (DOM manipulation)React
UI update modelImperative — you describe each stepDeclarative — you describe the end state
DOM interactionDirect (querySelector, innerHTML)Virtual DOM diff — React touches real DOM minimally
State trackingManual — sync every affected element by handAutomatic — React re-renders when state changes
Code reuseCopy-paste or custom modulesComponents — self-contained, composable, shareable
Data flowAd-hoc, any directionOne-way top-down via props — predictable and traceable
Learning curveLow initially, chaotic at scaleModerate upfront, scales cleanly to large apps
EcosystemBare — you build or find everythingRich — hooks, context, Next.js, React Query, etc.

🎯 Key Takeaways

  • React is declarative — you describe WHAT the UI should look like for a given state, not HOW to change it step by step. This is the mindset shift that makes everything else click.
  • State updates must go through the setter function (never mutate directly) because React detects changes by comparing references — a mutated object has the same reference and will be silently ignored.
  • The Virtual DOM isn't magic — it's a JavaScript object tree that React diffs against the previous render to compute the minimum real DOM changes. Understanding this explains React's performance model and why unnecessary re-renders matter.
  • The useEffect dependency array is a contract: list every external value your effect depends on. Missing dependencies cause stale data bugs; a missing cleanup function causes memory leaks and ghost state updates on unmounted components.

⚠ Common Mistakes to Avoid

  • Mistake 1: Mutating state directly instead of using the setter — e.g., cartItems.push(newItem) or user.name = 'Alice'. Symptom: the UI doesn't update even though the data changed in memory. Fix: always create a new value — use spread ([...cartItems, newItem]), Object.assign, or array methods like .filter() and .map() that return new arrays. React compares references, not deep equality.
  • Mistake 2: Forgetting the useEffect dependency array or leaving it wrong — e.g., referencing userId inside the effect but omitting it from the array. Symptom: the effect runs with a stale value of userId, or it never re-runs when userId changes and you see perpetually wrong data. Fix: include every variable from the outer scope that the effect reads or writes. The ESLint plugin eslint-plugin-react-hooks with the exhaustive-deps rule catches this automatically — enable it.
  • Mistake 3: Treating state updates as synchronous — e.g., calling setCount(count + 1) twice in the same function and expecting count to increment by 2. Symptom: only increments by 1. This happens because both calls close over the same stale count value. Fix: use the functional updater form setCount(prev => prev + 1) — this receives the latest state as an argument and composes correctly regardless of batching.

Interview Questions on This Topic

  • QWhat is the Virtual DOM and why does React use it instead of updating the real DOM directly? What are the performance trade-offs?
  • QExplain the difference between props and state. When would you choose to lift state up rather than keep it local to a component?
  • QWhat does the useEffect dependency array control, and what happens if you pass an empty array vs. no array at all? How do you prevent memory leaks from async operations inside useEffect?

Frequently Asked Questions

Is React a framework or a library?

React is strictly a UI library — it handles only the view layer. It has no built-in router, no HTTP client, and no global state manager. That's intentional: you compose it with best-of-breed tools like React Router, React Query, or Zustand. Frameworks like Next.js are built on top of React and add those missing pieces.

When should I use React instead of plain JavaScript?

Reach for React when your UI has multiple pieces of state that need to stay in sync, when the same UI component needs to appear in many places, or when your team is building something that will grow over months. For a simple static page or a single interactive widget, vanilla JS is perfectly fine and significantly lighter.

Why can't I just update state directly — why do I need useState?

React doesn't watch your variables for changes the way some frameworks do. It only knows to re-render a component when you call the setter from useState. If you update a plain variable, React has no knowledge that anything changed, so it never re-renders and your UI stays frozen. The useState setter is React's notification system — it's how you tell React 'something changed, please recalculate the UI'.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousWeb APIs OverviewNext →React Components and Props
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged