Web app state management explained with vanilla JavaScript

Web app state management explained with vanilla JavaScript

Learn how state management works in web apps by building a store from scratch with plain JavaScript.

State management is one of those concepts that makes complete sense once you see it working in plain code, and stays confusing as long as you only read about it in framework documentation.

This article builds a working state management system from scratch using a single HTML file and the browser window object. No React, no Redux, no bundler. By the end, you'll have a clear mental model of how stores, actions, reducers, and subscribers work, which makes every framework that builds on top of these concepts much easier to understand.

You can follow along on CodeSandbox.

What is state management in web apps?

State management is the practice of organizing, tracking, and updating the data your application depends on across components or across the full page. In small apps, a single JavaScript file handles this fine. In larger applications, data flows between many components from many directions, which creates inconsistencies unless you impose structure.

Tools like Redux and React-Redux formalize that structure. This tutorial shows you what that structure looks like under the hood.

How state management works

The pattern has five moving parts:

  • A store - a single object that holds all application data
  • A render function - rerenders the UI whenever the store changes
  • Actions - functions that describe how the store should change
  • Reducers - the logic inside actions that returns a new version of the store
  • A dispatch function - the single entry point for triggering any state change

Step 1: create the HTML file

Start with a plain index.html. No dependencies required.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>State management from scratch</title>
  </head>
  <body>
    <h1>Building state from scratch using a counter and input</h1>
  </body>
</html>

Step 2: add static HTML elements

Add two script tags and the UI elements, a counter with increment/decrement buttons and a text input.

The first script tag (inside <body>, before the content) will hold all state logic. The second (at the bottom of <body>) will handle DOM updates.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>State management from scratch</title>
  </head>
  <body>
    <script>
      // State logic goes here
    </script>
    <h1>Building state from scratch using a counter and input</h1>
    <button id="increment">Increment</button>
    <hr />
    <p id="counter_data">{counter} - {name}</p>
    <hr />
    <button id="decrement">Decrement</button>
    <hr />
    <input type="text" placeholder="Enter your name" />
    <script>
      // DOM updates go here
    </script>
  </body>
</html>

Static HTML page with counter and input elements

Step 3: create a data store

Use the browser's window object as a global store. This gives every script on the page access to the same data, the same thing a Redux store does in a React app.

<script>
  // State logic
  window.store = { counter: 0, name: "William" }
</script>

Step 4: create a render function

The render function reads from the store and updates the DOM. It uses a basic template approach, replacing {counter} and {name} placeholders with live values from the store.

<script>
  // DOM updates
  window.originalData = window.originalData || document.getElementById("counter_data").innerHTML;

  function renderData() {
    document.getElementById("counter_data").innerHTML = window.originalData
      .replace("{counter}", window.store.counter)
      .replace("{name}", window.store.name);
  }
  renderData();
</script>

window.originalData stores the original template string before any state changes, so every rerender starts from a clean template rather than replacing already-replaced values.

Counter and name rendered from the store

Step 5: create actions and reducers

Actions are functions that update the store data. Each one contains a reducer, the expression that computes the new value.

<script>
  window.store = { counter: 0, name: "William" };

  function increment() {
    window.store.counter += 1;
    renderData();
  }

  function decrement() {
    window.store.counter -= 1;
    renderData();
  }

  function setName(newName) {
    window.store.name = newName;
    renderData();
  }
</script>

Step 6: wire actions to UI events

Attach each action to its corresponding UI element.

<button id="increment" onclick="increment()">Increment</button>
<hr />
<p id="counter_data">{counter} - {name}</p>
<hr />
<button id="decrement" onclick="decrement()">Decrement</button>
<hr />
<input type="text" placeholder="Enter your name" onchange="setName(this.value)" />

The counter and input now work. There is one problem: the store is being mutated directly. Each action modifies window.store in place, which means there is no record of what the previous state was. Tools like Redux treat state as immutable, instead of modifying the store, each action returns a new version of it.

Step 7: add a dispatch function and immutable state

This is the step that makes the pattern look like Redux. A single dispatch function receives an action name and optional data, calls the corresponding action function, then merges the returned value into a new store object using spread syntax.

<script>
  window.store = { counter: 0, name: "William" };

  window.reduxStore = {
    dispatch(action, data) {
      const newData = window[action](data);
      window.store = { ...window.store, ...newData };
      renderData();
    }
  };

  function increment() {
    return { counter: window.store.counter + 1 };
  }

  function decrement() {
    return { counter: window.store.counter - 1 };
  }

  function setName(newName) {
    return { name: newName };
  }
</script>

Each action now returns a new value rather than modifying the store directly. The dispatch function merges that new value with the existing store using { ...window.store, ...newData }, creating a new object rather than mutating the original. This is the immutability pattern React and Redux rely on.

Update the buttons and input to dispatch actions instead of calling them directly:

<button id="increment" onclick="window.reduxStore.dispatch('increment')">Increment</button>
<hr />
<p id="counter_data">{counter} - {name}</p>
<hr />
<button id="decrement" onclick="window.reduxStore.dispatch('decrement')">Decrement</button>
<hr />
<input
  type="text"
  placeholder="Enter your name"
  onchange="window.reduxStore.dispatch('setName', this.value)"
/>

The action name passed to dispatch as a string is the same concept as a Redux action constant.

Working counter with immutable state

What you built

Here is the full data flow:

  1. A button click or input change calls dispatch with an action name
  2. dispatch calls the named action function and receives a reducer result
  3. The reducer result is spread into a new store object, the previous store is not modified
  4. renderData rerenders the DOM from the new store

This is the same pattern Redux formalizes with its createStore, combineReducers, and dispatch API. The difference is that Redux adds optimized rendering, middleware support, DevTools integration, and a subscription model, but the underlying data flow is identical to what you just built.

Where to go next

The Redux documentation covers the full API and the official patterns around actions, constants, and middleware. For React specifically, the React-Redux docs explain how the store connects to components through hooks like useSelector and useDispatch. The full code for this tutorial is on CodeSandbox.

FAQs

1, What is state management in web apps?

State management is how an application tracks and updates data that multiple components or parts of the UI depend on. Without a structured approach, data changes in one place can produce inconsistencies elsewhere. State management tools impose a single source of truth and a controlled update flow.

2, What is a Redux store?

A Redux store is a single JavaScript object that holds all application state. Components read from the store and dispatch actions to update it. The store never gets modified directly, each update produces a new store object, which is what allows Redux DevTools to replay state history.

3, What is a reducer in state management?

A reducer is a function that takes the current state and an action, and returns a new state. It does not modify the existing state, it returns a fresh copy with the changes applied. The name comes from the JavaScript Array.reduce method, which similarly takes an accumulator and produces a new value.

4, What is the difference between component state and application state?

Component state is local to a single component and managed with built-in methods like React's useState. Application state is shared across multiple components and requires a dedicated store. The counter in this tutorial is application state because both buttons and the display element all read from and write to the same store.

5, Do I need Redux for state management in React?

Not necessarily. React's built-in useContext and useReducer hooks handle many state management needs without a third-party library. Redux is worth adding when the application has complex shared state, needs time-travel debugging, or has a large team that benefits from strict conventions around state updates.

About author

I love solving problems, which has led me from core engineering to developer advocacy and product management. This is me sharing everything I know.

Book A Call!

Reach Your Technical Audience And Drive Product Adoption.

We are engineers, developer advocates, and marketers passionate about creating lasting value for SaaS teams. Partner with us to create the human-written developer marketing, SEO, demand-gen, and documentation content.

Get started

*35% less cost, risk-free, no lock-in.

Logo 1
Logo 2
Logo 3
Logo 4
Logo 5
Logo 6
Logo 7
Logo 8
Logo 9
Logo 10
Logo 11
Logo 12
Logo 13
Logo 14
Logo 15
Logo 16
Logo 17
Logo 18