AboutBlogContact
Frontend EngineeringAugust 28, 2013 6 min read 156Updated: June 22, 2026

Early Single Page Applications: Managing State Before Redux Existed

AunimedaAunimeda
📋 Table of Contents

Redux released in June 2015. Flux (Facebook's precursor) published in May 2014. Before these patterns existed, teams building single-page applications invented their own state management approaches - each project differently, each with distinct failure modes.

This is what we actually did in 2012-2013, building SPAs before the vocabulary existed.


The Core Problem

A Single Page Application never does a full page reload. It starts with an HTML shell and updates the DOM in response to user actions and server data. State - what data is loaded, what the user has selected, what form fields contain, what's in the cart - lives in JavaScript variables.

In a server-rendered app, state mostly lives in the database. Each page request is stateless; the server rebuilds the page from the database. SPAs break this model: state lives in JavaScript objects in the browser for the entire session.

Without a clear architecture, state scattered across the codebase:

// 2012 - state living everywhere
var currentUser = null;       // Global variable
var cartItems = [];           // Another global
var selectedFilters = {};     // Another global
var isLoading = false;        // Another global

// View A reads from globals
function renderHeader() {
    if (currentUser) {
        $('#username').text(currentUser.name);
    }
}

// View B modifies globals directly
function addToCart(productId) {
    cartItems.push(productId);
    // ... but does View C know the cart changed?
}

The bugs this produced: View C displayed a cart count that didn't update when View B added an item, because nothing told View C to re-render.


The Event Bus Pattern

Our first systematic solution was a global event bus - a central pub/sub object that any code could publish to or subscribe to:

// Global event bus
var EventBus = {
    _events: {},
    
    on: function(event, callback) {
        if (!this._events[event]) {
            this._events[event] = [];
        }
        this._events[event].push(callback);
        return this;  // For chaining
    },
    
    off: function(event, callback) {
        if (this._events[event]) {
            this._events[event] = this._events[event].filter(function(cb) {
                return cb !== callback;
            });
        }
    },
    
    trigger: function(event, data) {
        if (this._events[event]) {
            this._events[event].forEach(function(callback) {
                callback(data);
            });
        }
    }
};

// Usage
// Cart module publishes events
var Cart = {
    items: [],
    
    add: function(product) {
        this.items.push(product);
        EventBus.trigger('cart:changed', { items: this.items, count: this.items.length });
    },
    
    remove: function(productId) {
        this.items = this.items.filter(function(item) {
            return item.id !== productId;
        });
        EventBus.trigger('cart:changed', { items: this.items, count: this.items.length });
    }
};

// Header subscribes to cart events
EventBus.on('cart:changed', function(data) {
    $('#cart-count').text(data.count);
    if (data.count > 0) {
        $('#cart-count').show();
    }
});

// Cart overlay subscribes too
EventBus.on('cart:changed', function(data) {
    renderCartItems(data.items);
});

This worked for simple cases. It broke down when the subscriber order mattered (event handlers fired in subscription order, which was fragile), when subscriptions leaked (forgetting to call off when a view was destroyed), and when debugging - an event firing caused a cascade of handlers, and tracing what changed what required reading all subscribers.


URL as State: The Hash Router

In a multi-page app, the URL is the canonical state representation. In an SPA, the URL didn't change on navigation - which broke the back button and made sharing links impossible.

The solution before HTML5 pushState: the URL hash. yoursite.com/#/products/42. The # part was handled by the browser without a page reload; JavaScript could read and change it freely.

// Simple hash router - 2012 style
var Router = {
    routes: {},
    
    // Define routes
    on: function(pattern, handler) {
        this.routes[pattern] = handler;
    },
    
    // Parse the current hash and execute matching route
    navigate: function() {
        var hash = window.location.hash.slice(1) || '/';
        
        for (var pattern in this.routes) {
            var regex = new RegExp('^' + pattern.replace(':id', '([^/]+)') + '$');
            var match = hash.match(regex);
            
            if (match) {
                // Call handler with URL params
                this.routes[pattern].apply(null, match.slice(1));
                return;
            }
        }
        
        // No route matched
        this.routes['*'] && this.routes['*']();
    },
    
    // Navigate to a route
    go: function(path) {
        window.location.hash = path;
    }
};

// Handle back/forward navigation
window.addEventListener('hashchange', function() {
    Router.navigate();
});

// Initial route on page load
window.addEventListener('DOMContentLoaded', function() {
    Router.navigate();
});

// Define routes
Router.on('/', function() {
    renderHomePage();
});

Router.on('/products', function() {
    renderProductList();
});

Router.on('/products/:id', function(productId) {
    renderProductDetail(productId);
});

Router.on('/cart', function() {
    renderCart();
});

Router.on('*', function() {
    render404();
});

HTML5 pushState (available from 2010, widely usable from 2012 when IE9+ adoption grew) let you change the URL without the hash:

// HTML5 pushState router
window.history.pushState({ page: 'product', id: 42 }, '', '/products/42');

// Handle popstate (back/forward)
window.addEventListener('popstate', function(event) {
    if (event.state) {
        renderPage(event.state.page, event.state.id);
    }
});

But pushState required server-side support - the server needed to serve the SPA shell for all routes (not just the root URL), because a hard refresh on /products/42 would actually hit the server.

# Nginx config for HTML5 SPA routing
server {
    listen 80;
    root /var/www/app/dist;
    
    location / {
        try_files $uri $uri/ /index.html;
        # If file exists, serve it; otherwise serve index.html
        # This lets the SPA handle routing
    }
}

LocalStorage as Persistent State

Keeping cart state through a page refresh required persistence. Cookies were limited (4KB, sent with every HTTP request). LocalStorage (HTML5, available from 2009, reliable from 2011) offered 5-10MB per domain:

var CartStore = {
    STORAGE_KEY: 'cart_items',
    
    // Load from localStorage on initialization
    items: JSON.parse(localStorage.getItem('cart_items') || '[]'),
    
    add: function(product) {
        this.items.push(product);
        this._persist();
        EventBus.trigger('cart:changed', { items: this.items });
    },
    
    remove: function(productId) {
        this.items = this.items.filter(function(item) {
            return item.id !== productId;
        });
        this._persist();
        EventBus.trigger('cart:changed', { items: this.items });
    },
    
    _persist: function() {
        try {
            localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.items));
        } catch (e) {
            // localStorage full or unavailable (private browsing)
            console.warn('Could not persist cart:', e.message);
        }
    },
    
    clear: function() {
        this.items = [];
        localStorage.removeItem(this.STORAGE_KEY);
    }
};

The try/catch was necessary - Safari in private browsing mode threw an exception on any localStorage write. This was discovered in production, after a user reported their cart clearing every time they added an item.


What Redux Solved

When Dan Abramov published Redux in 2015, it solved the problems we'd been patching with event buses and scattered state:

Single source of truth: All application state in one store. No asking "which variable has the current cart count?"

State is read-only: You don't mutate state directly; you dispatch actions. Debugging is possible because you can replay the sequence of actions.

Pure reducer functions: State transitions are predictable, testable functions.

Looking at our 2013 event bus and global state code from the perspective of Redux makes clear what was missing: discipline. The event bus pattern is fine; it's pub/sub. The problem was that publishers could mutate shared state directly AND publish events, so state could change without the event bus knowing. Redux prevents direct mutation - the only way to change state is through a dispatched action.

The vocabulary of "unidirectional data flow" that Redux popularized was what we were trying to achieve manually in 2012-2013. We mostly succeeded on small apps. On large apps with many contributors, it fell apart into spaghetti.

Redux made the architecture explicit, documentable, and enforceable. That's the lesson from the pre-Redux era: good architecture ideas need tooling to enforce them, especially as teams grow.


Aunimeda builds modern web frontends - from single-page applications to complex multi-locale sites.

Contact us to discuss your frontend project. See also: Web Development, Corporate Website Development

Read Also

React vs Angular 2: Why We Chose React for Our SaaS CRM in 2016aunimeda
Frontend Engineering

React vs Angular 2: Why We Chose React for Our SaaS CRM in 2016

After evaluating both for a complex CRM build, we chose React + Redux. The honest comparison: two-way binding vs unidirectional data flow, component philosophy, and what actually mattered at scale.

How to Set Up React with Webpack 1.x Without Create React App (2015)aunimeda
Frontend Engineering

How to Set Up React with Webpack 1.x Without Create React App (2015)

In 2015, Create React App didn't exist. Setting up React meant manually configuring Webpack 1.x, Babel 5, and Hot Module Replacement. Here's the exact working config we used in production - and why each piece was necessary.

The jQuery Era (2008-2015): When One Library United the Webaunimeda
Frontend Engineering

The jQuery Era (2008-2015): When One Library United the Web

Before React, Vue, or Angular, there was jQuery. For seven years it was the answer to virtually every frontend problem. We wrote hundreds of thousands of lines of jQuery code. Here's what that era actually looked like - and what it taught us that still applies.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Web Development

Get Consultation All articles