The History API allows manipulation of browser session history. This article covers its usage and SPA routing implementation.

Basic Concepts

History Object

// History length
console.log(history.length);

// Current state object
console.log(history.state);

// Scroll restoration behavior
history.scrollRestoration = 'manual'; // or 'auto'

Basic Navigation

// Go back one page
history.back();

// Go forward one page
history.forward();

// Navigate to specific position
history.go(-2); // Go back two pages
history.go(1);  // Go forward one page
history.go(0);  // Refresh current page

Core Methods

pushState

// Add history entry
history.pushState(state, title, url);

// Parameters
// state: State object associated with new history entry
// title: New page title (ignored by most browsers)
// url: New URL (optional, must be same origin)

// Example
history.pushState(
  { page: 1, query: 'javascript' },
  '',
  '/search?q=javascript&page=1'
);

// State object size limit (typically 640KB)
const largeState = { data: new Array(100000).fill('x') };
try {
  history.pushState(largeState, '', '/large');
} catch (e) {
  console.error('State object too large:', e);
}

replaceState

// Replace current history entry
history.replaceState(state, title, url);

// Example: Update current page state
history.replaceState(
  { ...history.state, scrollPosition: window.scrollY },
  '',
  location.href
);

// Fix URL
if (location.pathname === '/old-path') {
  history.replaceState(null, '', '/new-path');
}

popstate Event

// Listen for history navigation
window.addEventListener('popstate', (event) => {
  console.log('Navigation occurred');
  console.log('State:', event.state);
  
  // Update page based on state
  if (event.state) {
    updatePage(event.state);
  }
});

// Note: pushState/replaceState don't trigger popstate
// Only triggered by back/forward buttons or history.back()/forward()/go()

SPA Router Implementation

Basic Router

class Router {
  constructor() {
    this.routes = new Map();
    this.currentRoute = null;
    
    window.addEventListener('popstate', (e) => {
      this.handleRoute(location.pathname, e.state);
    });
  }
  
  // Register route
  register(path, handler) {
    this.routes.set(path, handler);
    return this;
  }
  
  // Navigate to path
  navigate(path, state = {}) {
    if (path === location.pathname) return;
    
    history.pushState(state, '', path);
    this.handleRoute(path, state);
  }
  
  // Replace current route
  replace(path, state = {}) {
    history.replaceState(state, '', path);
    this.handleRoute(path, state);
  }
  
  // Handle route
  handleRoute(path, state) {
    // Exact match
    if (this.routes.has(path)) {
      this.currentRoute = path;
      this.routes.get(path)(state);
      return;
    }
    
    // Parameter route matching
    for (const [pattern, handler] of this.routes) {
      const params = this.matchRoute(pattern, path);
      if (params) {
        this.currentRoute = path;
        handler({ ...state, params });
        return;
      }
    }
    
    // 404 handling
    if (this.routes.has('*')) {
      this.routes.get('*')(state);
    }
  }
  
  // Route parameter matching
  matchRoute(pattern, path) {
    const patternParts = pattern.split('/');
    const pathParts = path.split('/');
    
    if (patternParts.length !== pathParts.length) {
      return null;
    }
    
    const params = {};
    
    for (let i = 0; i < patternParts.length; i++) {
      if (patternParts[i].startsWith(':')) {
        params[patternParts[i].slice(1)] = pathParts[i];
      } else if (patternParts[i] !== pathParts[i]) {
        return null;
      }
    }
    
    return params;
  }
  
  // Initialize
  init() {
    this.handleRoute(location.pathname, history.state);
  }
}

// Usage
const router = new Router();

router
  .register('/', () => showHome())
  .register('/about', () => showAbout())
  .register('/users/:id', ({ params }) => showUser(params.id))
  .register('*', () => show404())
  .init();

Router with Middleware

class AdvancedRouter {
  constructor() {
    this.routes = [];
    this.middlewares = [];
    this.beforeHooks = [];
    this.afterHooks = [];
    
    window.addEventListener('popstate', (e) => {
      this.resolve(location.pathname, e.state);
    });
  }
  
  // Add middleware
  use(middleware) {
    this.middlewares.push(middleware);
    return this;
  }
  
  // Navigation guards
  beforeEach(hook) {
    this.beforeHooks.push(hook);
    return this;
  }
  
  afterEach(hook) {
    this.afterHooks.push(hook);
    return this;
  }
  
  // Register route
  route(path, handler, meta = {}) {
    const regex = this.pathToRegex(path);
    this.routes.push({ path, regex, handler, meta });
    return this;
  }
  
  // Path to regex
  pathToRegex(path) {
    const pattern = path
      .replace(/\//g, '\\/')
      .replace(/:(\w+)/g, '(?<$1>[^/]+)')
      .replace(/\*/g, '.*');
    return new RegExp(`^${pattern}$`);
  }
  
  // Navigate
  async navigate(path, state = {}) {
    const from = { path: location.pathname, state: history.state };
    const to = { path, state };
    
    // Execute before guards
    for (const hook of this.beforeHooks) {
      const result = await hook(to, from);
      if (result === false) return;
      if (typeof result === 'string') {
        return this.navigate(result, state);
      }
    }
    
    history.pushState(state, '', path);
    await this.resolve(path, state);
    
    // Execute after guards
    for (const hook of this.afterHooks) {
      await hook(to, from);
    }
  }
  
  // Resolve route
  async resolve(path, state) {
    // Execute middleware
    const context = { path, state, params: {} };
    
    for (const middleware of this.middlewares) {
      await middleware(context);
    }
    
    // Match route
    for (const route of this.routes) {
      const match = path.match(route.regex);
      if (match) {
        context.params = match.groups || {};
        context.meta = route.meta;
        await route.handler(context);
        return;
      }
    }
  }
  
  init() {
    this.resolve(location.pathname, history.state);
  }
}

// Usage
const router = new AdvancedRouter();

// Middleware
router.use(async (ctx) => {
  console.log('Visiting:', ctx.path);
  ctx.startTime = Date.now();
});

// Navigation guards
router.beforeEach(async (to, from) => {
  if (to.path.startsWith('/admin') && !isLoggedIn()) {
    return '/login';
  }
});

router.afterEach((to, from) => {
  // Page view analytics
  analytics.pageView(to.path);
});

// Routes
router
  .route('/', homeHandler)
  .route('/users/:id', userHandler, { requiresAuth: true })
  .init();
class LinkInterceptor {
  constructor(router) {
    this.router = router;
    this.init();
  }
  
  init() {
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a');
      if (!link) return;
      
      // Check if should intercept
      if (this.shouldIntercept(link)) {
        e.preventDefault();
        this.router.navigate(link.pathname + link.search);
      }
    });
  }
  
  shouldIntercept(link) {
    // External links
    if (link.origin !== location.origin) return false;
    
    // New tab
    if (link.target === '_blank') return false;
    
    // Download links
    if (link.hasAttribute('download')) return false;
    
    // Explicitly disabled
    if (link.dataset.noIntercept !== undefined) return false;
    
    // Special protocols
    if (!/^https?:$/.test(link.protocol)) return false;
    
    return true;
  }
}

// Usage
new LinkInterceptor(router);

State Management

Page State Persistence

class PageStateManager {
  constructor() {
    this.stateKey = 'pageState';
    
    window.addEventListener('popstate', (e) => {
      this.restore(e.state);
    });
    
    window.addEventListener('beforeunload', () => {
      this.save();
    });
  }
  
  // Save state
  save() {
    const state = {
      scrollX: window.scrollX,
      scrollY: window.scrollY,
      formData: this.getFormData(),
      timestamp: Date.now()
    };
    
    history.replaceState(
      { ...history.state, [this.stateKey]: state },
      ''
    );
  }
  
  // Restore state
  restore(historyState) {
    const state = historyState?.[this.stateKey];
    if (!state) return;
    
    // Restore scroll position
    requestAnimationFrame(() => {
      window.scrollTo(state.scrollX, state.scrollY);
    });
    
    // Restore form data
    if (state.formData) {
      this.restoreFormData(state.formData);
    }
  }
  
  getFormData() {
    const forms = document.querySelectorAll('form[data-persist]');
    const data = {};
    
    forms.forEach((form, index) => {
      const formData = new FormData(form);
      data[index] = Object.fromEntries(formData.entries());
    });
    
    return data;
  }
  
  restoreFormData(data) {
    const forms = document.querySelectorAll('form[data-persist]');
    
    forms.forEach((form, index) => {
      if (data[index]) {
        Object.entries(data[index]).forEach(([name, value]) => {
          const input = form.elements[name];
          if (input) input.value = value;
        });
      }
    });
  }
}

const stateManager = new PageStateManager();
class NavigationStack {
  constructor(maxSize = 50) {
    this.maxSize = maxSize;
    this.stack = [];
    this.currentIndex = -1;
    
    // Initialize
    this.push(location.pathname, history.state);
    
    window.addEventListener('popstate', (e) => {
      this.onPopState(e);
    });
  }
  
  push(path, state) {
    // Remove entries after current position
    this.stack = this.stack.slice(0, this.currentIndex + 1);
    
    // Add new entry
    this.stack.push({ path, state, timestamp: Date.now() });
    this.currentIndex++;
    
    // Limit size
    if (this.stack.length > this.maxSize) {
      this.stack.shift();
      this.currentIndex--;
    }
  }
  
  onPopState(event) {
    const path = location.pathname;
    
    // Determine if back or forward
    if (this.currentIndex > 0 && 
        this.stack[this.currentIndex - 1]?.path === path) {
      this.currentIndex--;
    } else if (this.currentIndex < this.stack.length - 1 &&
               this.stack[this.currentIndex + 1]?.path === path) {
      this.currentIndex++;
    }
  }
  
  canGoBack() {
    return this.currentIndex > 0;
  }
  
  canGoForward() {
    return this.currentIndex < this.stack.length - 1;
  }
  
  getHistory() {
    return [...this.stack];
  }
  
  getCurrent() {
    return this.stack[this.currentIndex];
  }
}

const navStack = new NavigationStack();

Practical Applications

Infinite Scroll Pagination

class InfiniteScrollWithHistory {
  constructor(options) {
    this.options = {
      container: document.body,
      loadMore: async () => [],
      itemsPerPage: 20,
      ...options
    };
    
    this.page = this.getPageFromUrl();
    this.items = [];
    
    this.init();
  }
  
  getPageFromUrl() {
    const params = new URLSearchParams(location.search);
    return parseInt(params.get('page')) || 1;
  }
  
  updateUrl(page) {
    const url = new URL(location.href);
    url.searchParams.set('page', page);
    
    history.replaceState(
      { page, scrollY: window.scrollY },
      '',
      url.toString()
    );
  }
  
  async init() {
    // Load initial data
    for (let i = 1; i <= this.page; i++) {
      const items = await this.options.loadMore(i);
      this.items.push(...items);
    }
    
    this.render();
    
    // Restore scroll position
    if (history.state?.scrollY) {
      window.scrollTo(0, history.state.scrollY);
    }
    
    // Setup infinite scroll
    this.setupInfiniteScroll();
    
    // Listen for history navigation
    window.addEventListener('popstate', (e) => {
      if (e.state?.page) {
        this.page = e.state.page;
        window.scrollTo(0, e.state.scrollY || 0);
      }
    });
  }
  
  setupInfiniteScroll() {
    const observer = new IntersectionObserver(async (entries) => {
      if (entries[0].isIntersecting) {
        this.page++;
        const newItems = await this.options.loadMore(this.page);
        this.items.push(...newItems);
        this.render();
        this.updateUrl(this.page);
      }
    });
    
    observer.observe(document.querySelector('#load-more-trigger'));
  }
  
  render() {
    // Render list
  }
}
class ModalHistoryManager {
  constructor() {
    this.modals = new Map();
    this.activeModals = [];
    
    window.addEventListener('popstate', (e) => {
      this.handlePopState(e);
    });
  }
  
  register(id, modal) {
    this.modals.set(id, modal);
  }
  
  open(id, data = {}) {
    const modal = this.modals.get(id);
    if (!modal) return;
    
    // Add history entry
    history.pushState(
      { modalId: id, modalData: data },
      '',
      `${location.pathname}?modal=${id}`
    );
    
    this.activeModals.push(id);
    modal.show(data);
  }
  
  close(id) {
    const modal = this.modals.get(id);
    if (!modal) return;
    
    // Go back in history
    if (this.activeModals.includes(id)) {
      history.back();
    }
  }
  
  handlePopState(event) {
    const state = event.state;
    
    // Close current modal
    if (this.activeModals.length > 0) {
      const currentModalId = this.activeModals.pop();
      const modal = this.modals.get(currentModalId);
      modal?.hide();
    }
    
    // If state has modal
    if (state?.modalId) {
      const modal = this.modals.get(state.modalId);
      if (modal) {
        this.activeModals.push(state.modalId);
        modal.show(state.modalData);
      }
    }
  }
}

// Usage
const modalManager = new ModalHistoryManager();

modalManager.register('userProfile', {
  show: (data) => document.getElementById('userModal').classList.add('active'),
  hide: () => document.getElementById('userModal').classList.remove('active')
});

// Open modal
document.querySelector('.open-modal').addEventListener('click', () => {
  modalManager.open('userProfile', { userId: 123 });
});

Tab Navigation

class TabNavigation {
  constructor(container) {
    this.container = container;
    this.tabs = container.querySelectorAll('[data-tab]');
    this.panels = container.querySelectorAll('[data-panel]');
    
    this.init();
  }
  
  init() {
    // Restore from URL
    const hash = location.hash.slice(1);
    if (hash) {
      this.activate(hash, false);
    }
    
    // Listen for tab clicks
    this.tabs.forEach(tab => {
      tab.addEventListener('click', (e) => {
        e.preventDefault();
        const tabId = tab.dataset.tab;
        this.activate(tabId, true);
      });
    });
    
    // Listen for history navigation
    window.addEventListener('popstate', () => {
      const hash = location.hash.slice(1);
      this.activate(hash || this.tabs[0].dataset.tab, false);
    });
  }
  
  activate(tabId, updateHistory = true) {
    // Update tab states
    this.tabs.forEach(tab => {
      tab.classList.toggle('active', tab.dataset.tab === tabId);
    });
    
    // Update panel visibility
    this.panels.forEach(panel => {
      panel.classList.toggle('active', panel.dataset.panel === tabId);
    });
    
    // Update URL
    if (updateHistory) {
      history.pushState({ tab: tabId }, '', `#${tabId}`);
    }
  }
}

// Usage
new TabNavigation(document.querySelector('.tabs-container'));

Best Practices Summary

History API Best Practices:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   State Management                                  │
│   ├── Keep state objects minimal                   │
│   ├── Serialize sensitive data                     │
│   ├── Use replaceState appropriately               │
│   └── Handle state restoration                     │
│                                                     │
│   Routing Design                                    │
│   ├── Use semantic URLs                            │
│   ├── Support direct access                        │
│   ├── Handle 404 cases                             │
│   └── Implement navigation guards                  │
│                                                     │
│   User Experience                                   │
│   ├── Keep back button functional                  │
│   ├── Restore scroll position                      │
│   ├── Avoid duplicate history entries              │
│   └── Handle external links                        │
│                                                     │
└─────────────────────────────────────────────────────┘
MethodPurposeTriggers popstate
pushStateAdd history entryNo
replaceStateReplace current entryNo
back/forward/goNavigate historyYes

Master the History API to build seamless single-page application navigation.