Web APIs

JavaScript History API: Complete Navigation Control Guide

Master the History API in JavaScript for single-page application navigation. Learn pushState, replaceState, and building modern routing systems.

By JavaScriptDoc Team
historynavigationsparoutingjavascript

JavaScript History API: Complete Navigation Control Guide

The History API provides powerful methods to manipulate the browser session history, enabling you to build single-page applications with proper navigation, back/forward functionality, and shareable URLs.

Understanding the History API

The History API allows you to modify the browser's session history and respond to navigation events without full page reloads.

// Check History API support
if (window.history && window.history.pushState) {
  console.log('History API is supported');
} else {
  console.log('History API is not supported');
}

// Basic history navigation
console.log('Current history length:', window.history.length);

// Navigate back and forward
window.history.back(); // Go back one step
window.history.forward(); // Go forward one step
window.history.go(-2); // Go back two steps
window.history.go(1); // Go forward one step

// Current state
console.log('Current state:', window.history.state);

Managing History State

pushState Method

class HistoryManager {
  constructor() {
    this.stateId = 0;
    this.states = new Map();
    this.initializeListeners();
  }

  // Push new state to history
  pushState(data, title, url) {
    const state = {
      id: ++this.stateId,
      data: data,
      timestamp: Date.now(),
      url: url || window.location.pathname,
    };

    // Store state reference
    this.states.set(state.id, state);

    // Push to browser history
    window.history.pushState(state, title, url);

    // Dispatch custom event
    this.dispatchStateChange('push', state);

    return state;
  }

  // Replace current state
  replaceState(data, title, url) {
    const state = {
      id: this.stateId,
      data: data,
      timestamp: Date.now(),
      url: url || window.location.pathname,
    };

    // Update stored state
    this.states.set(state.id, state);

    // Replace in browser history
    window.history.replaceState(state, title, url);

    // Dispatch custom event
    this.dispatchStateChange('replace', state);

    return state;
  }

  // Navigate with state
  navigateTo(path, data = {}) {
    // Check if same path
    if (window.location.pathname === path) {
      this.replaceState(data, '', path);
    } else {
      this.pushState(data, '', path);
    }

    // Update document title
    if (data.title) {
      document.title = data.title;
    }
  }

  // Go back with callback
  goBack(callback) {
    const currentLength = window.history.length;

    window.history.back();

    // Check if navigation happened
    setTimeout(() => {
      if (window.history.length === currentLength) {
        // Navigation was blocked or at first page
        if (callback) callback(false);
      } else {
        if (callback) callback(true);
      }
    }, 50);
  }

  // Initialize event listeners
  initializeListeners() {
    // Listen for popstate events
    window.addEventListener('popstate', (event) => {
      this.handlePopState(event);
    });

    // Intercept link clicks
    document.addEventListener('click', (event) => {
      const link = event.target.closest('a[href]');
      if (link && this.shouldInterceptLink(link)) {
        event.preventDefault();
        this.handleLinkClick(link);
      }
    });
  }

  // Handle browser back/forward
  handlePopState(event) {
    const state = event.state;

    if (state && state.id) {
      const storedState = this.states.get(state.id);
      if (storedState) {
        this.dispatchStateChange('pop', storedState);
      }
    } else {
      // Handle initial page load
      this.dispatchStateChange('pop', {
        url: window.location.pathname,
        data: {},
      });
    }
  }

  // Check if link should be intercepted
  shouldInterceptLink(link) {
    // Only intercept internal links
    const href = link.getAttribute('href');
    const url = new URL(href, window.location.href);

    return (
      url.origin === window.location.origin &&
      !link.hasAttribute('download') &&
      link.target !== '_blank' &&
      !link.classList.contains('external')
    );
  }

  // Handle intercepted link click
  handleLinkClick(link) {
    const href = link.getAttribute('href');
    const data = {
      source: 'link',
      element: link.tagName,
      ...link.dataset,
    };

    this.navigateTo(href, data);
  }

  // Dispatch state change event
  dispatchStateChange(type, state) {
    const event = new CustomEvent('statechange', {
      detail: {
        type: type,
        state: state,
        url: state.url,
      },
    });

    window.dispatchEvent(event);
  }

  // Get current state
  getCurrentState() {
    return window.history.state;
  }

  // Get state by ID
  getState(id) {
    return this.states.get(id);
  }
}

// Usage
const history = new HistoryManager();

// Listen for state changes
window.addEventListener('statechange', (event) => {
  console.log('State changed:', event.detail);
  // Update UI based on new state
  updatePage(event.detail.state);
});

// Navigate programmatically
history.navigateTo('/products', {
  category: 'electronics',
  page: 1,
});

// Navigate with custom data
history.pushState(
  { productId: 123, viewed: true },
  'Product - Smart Watch',
  '/products/smart-watch'
);

Building a Router

Single-Page Application Router

class Router {
  constructor() {
    this.routes = new Map();
    this.middlewares = [];
    this.currentRoute = null;
    this.history = new HistoryManager();
    this.init();
  }

  // Register a route
  route(path, handler, options = {}) {
    const route = {
      path: path,
      pattern: this.pathToRegex(path),
      handler: handler,
      options: options,
    };

    this.routes.set(path, route);
    return this;
  }

  // Convert path to regex
  pathToRegex(path) {
    const pattern = path
      .replace(/\//g, '\\/')
      .replace(/:(\w+)/g, '(?<$1>[^/]+)')
      .replace(/\*/g, '.*');

    return new RegExp(`^${pattern}$`);
  }

  // Add middleware
  use(middleware) {
    this.middlewares.push(middleware);
    return this;
  }

  // Navigate to path
  async navigate(path, data = {}) {
    const route = this.matchRoute(path);

    if (!route) {
      await this.handleNotFound(path);
      return;
    }

    // Create context
    const context = {
      path: path,
      params: route.params,
      query: this.parseQuery(window.location.search),
      data: data,
      route: route.route,
    };

    // Run middlewares
    const shouldContinue = await this.runMiddlewares(context);
    if (!shouldContinue) return;

    // Update history
    this.history.pushState(context, '', path);

    // Execute route handler
    await this.executeRoute(route.route, context);
  }

  // Match route to path
  matchRoute(path) {
    for (const [_, route] of this.routes) {
      const match = path.match(route.pattern);
      if (match) {
        return {
          route: route,
          params: match.groups || {},
        };
      }
    }

    return null;
  }

  // Parse query string
  parseQuery(queryString) {
    const params = new URLSearchParams(queryString);
    const query = {};

    for (const [key, value] of params) {
      if (query[key]) {
        if (Array.isArray(query[key])) {
          query[key].push(value);
        } else {
          query[key] = [query[key], value];
        }
      } else {
        query[key] = value;
      }
    }

    return query;
  }

  // Run middlewares
  async runMiddlewares(context) {
    for (const middleware of this.middlewares) {
      const result = await middleware(context);
      if (result === false) {
        return false;
      }
    }
    return true;
  }

  // Execute route handler
  async executeRoute(route, context) {
    try {
      // Set current route
      this.currentRoute = route;

      // Call handler
      await route.handler(context);

      // Dispatch route change event
      this.dispatchRouteChange(route, context);
    } catch (error) {
      console.error('Route execution error:', error);
      await this.handleError(error, context);
    }
  }

  // Handle not found
  async handleNotFound(path) {
    const notFoundRoute = this.routes.get('*') || this.routes.get('/404');

    if (notFoundRoute) {
      await this.executeRoute(notFoundRoute, { path, params: {} });
    } else {
      console.error('No route found for:', path);
    }
  }

  // Handle errors
  async handleError(error, context) {
    const errorRoute = this.routes.get('/error');

    if (errorRoute) {
      await this.executeRoute(errorRoute, { ...context, error });
    } else {
      console.error('Unhandled route error:', error);
    }
  }

  // Initialize router
  init() {
    // Listen for state changes
    window.addEventListener('statechange', (event) => {
      if (event.detail.type === 'pop') {
        this.handleStateChange(event.detail.state);
      }
    });

    // Handle initial load
    this.navigate(window.location.pathname);
  }

  // Handle browser navigation
  handleStateChange(state) {
    const path = state.url || window.location.pathname;
    const route = this.matchRoute(path);

    if (route) {
      const context = {
        path: path,
        params: route.params,
        query: this.parseQuery(window.location.search),
        data: state.data || {},
        route: route.route,
        isBack: true,
      };

      this.executeRoute(route.route, context);
    }
  }

  // Dispatch route change event
  dispatchRouteChange(route, context) {
    const event = new CustomEvent('routechange', {
      detail: {
        route: route,
        context: context,
      },
    });

    window.dispatchEvent(event);
  }

  // Redirect to another route
  redirect(path, data = {}) {
    this.history.replaceState(data, '', path);
    this.navigate(path, data);
  }

  // Get current route
  getCurrentRoute() {
    return this.currentRoute;
  }

  // Check if route is active
  isActive(path) {
    return window.location.pathname === path;
  }
}

// Usage
const router = new Router();

// Add middleware
router.use(async (context) => {
  console.log('Navigating to:', context.path);

  // Check authentication
  if (context.route.options.requiresAuth && !isAuthenticated()) {
    router.redirect('/login');
    return false;
  }

  return true;
});

// Define routes
router
  .route('/', async (context) => {
    console.log('Home page');
    document.getElementById('app').innerHTML = '<h1>Home</h1>';
  })
  .route('/products', async (context) => {
    const { category, page = 1 } = context.query;
    console.log('Products page:', { category, page });

    const products = await fetchProducts({ category, page });
    renderProducts(products);
  })
  .route('/products/:id', async (context) => {
    const productId = context.params.id;
    console.log('Product detail:', productId);

    const product = await fetchProduct(productId);
    renderProductDetail(product);
  })
  .route('/user/:username/posts/:postId?', async (context) => {
    const { username, postId } = context.params;

    if (postId) {
      // Show specific post
      const post = await fetchPost(username, postId);
      renderPost(post);
    } else {
      // Show all posts
      const posts = await fetchUserPosts(username);
      renderPosts(posts);
    }
  })
  .route('*', async (context) => {
    console.log('404 - Not found:', context.path);
    document.getElementById('app').innerHTML = '<h1>404 - Page Not Found</h1>';
  });

// Navigate programmatically
router.navigate('/products?category=electronics');

// Listen for route changes
window.addEventListener('routechange', (event) => {
  console.log('Route changed:', event.detail);
  updateActiveNavigation(event.detail.context.path);
});

Advanced History Features

History State Management

class AdvancedHistoryManager {
  constructor() {
    this.maxHistorySize = 50;
    this.sessionKey = 'app_history_session';
    this.initializeSession();
  }

  // Initialize session storage
  initializeSession() {
    const session = this.getSession();

    if (!session || session.id !== this.getSessionId()) {
      this.createNewSession();
    }

    // Restore state on page load
    this.restoreState();
  }

  // Get current session ID
  getSessionId() {
    return window.performance.navigation.type === 1
      ? sessionStorage.getItem('session_id')
      : Date.now().toString();
  }

  // Get session data
  getSession() {
    try {
      const data = sessionStorage.getItem(this.sessionKey);
      return data ? JSON.parse(data) : null;
    } catch (error) {
      console.error('Failed to get session:', error);
      return null;
    }
  }

  // Create new session
  createNewSession() {
    const session = {
      id: this.getSessionId(),
      created: Date.now(),
      history: [],
      scrollPositions: new Map(),
    };

    sessionStorage.setItem('session_id', session.id);
    this.saveSession(session);
  }

  // Save session data
  saveSession(session) {
    try {
      // Limit history size
      if (session.history.length > this.maxHistorySize) {
        session.history = session.history.slice(-this.maxHistorySize);
      }

      sessionStorage.setItem(this.sessionKey, JSON.stringify(session));
    } catch (error) {
      console.error('Failed to save session:', error);

      // Handle quota exceeded
      if (error.name === 'QuotaExceededError') {
        this.clearOldestEntries();
      }
    }
  }

  // Push state with metadata
  pushStateWithMetadata(state, title, url, metadata = {}) {
    const enhancedState = {
      ...state,
      _metadata: {
        timestamp: Date.now(),
        title: title || document.title,
        scrollPosition: this.getScrollPosition(),
        viewport: this.getViewport(),
        ...metadata,
      },
    };

    // Save to browser history
    window.history.pushState(enhancedState, title, url);

    // Update session
    const session = this.getSession();
    session.history.push({
      url: url,
      state: enhancedState,
      timestamp: enhancedState._metadata.timestamp,
    });

    this.saveSession(session);
  }

  // Get scroll position
  getScrollPosition() {
    return {
      x: window.pageXOffset || document.documentElement.scrollLeft,
      y: window.pageYOffset || document.documentElement.scrollTop,
    };
  }

  // Get viewport dimensions
  getViewport() {
    return {
      width: window.innerWidth,
      height: window.innerHeight,
    };
  }

  // Restore scroll position
  restoreScrollPosition(state) {
    if (state && state._metadata && state._metadata.scrollPosition) {
      const { x, y } = state._metadata.scrollPosition;

      // Defer to next frame for better reliability
      requestAnimationFrame(() => {
        window.scrollTo(x, y);
      });
    }
  }

  // Restore state on load
  restoreState() {
    const state = window.history.state;

    if (state && state._metadata) {
      // Restore document title
      if (state._metadata.title) {
        document.title = state._metadata.title;
      }

      // Restore scroll position
      this.restoreScrollPosition(state);
    }
  }

  // Get history entries
  getHistoryEntries() {
    const session = this.getSession();
    return session ? session.history : [];
  }

  // Navigate to specific entry
  navigateToEntry(index) {
    const entries = this.getHistoryEntries();
    const currentIndex = this.getCurrentIndex();

    if (index >= 0 && index < entries.length) {
      const delta = index - currentIndex;
      window.history.go(delta);
    }
  }

  // Get current index in history
  getCurrentIndex() {
    const currentUrl = window.location.href;
    const entries = this.getHistoryEntries();

    for (let i = entries.length - 1; i >= 0; i--) {
      if (entries[i].url === currentUrl) {
        return i;
      }
    }

    return -1;
  }

  // Clear oldest entries
  clearOldestEntries() {
    const session = this.getSession();

    if (session && session.history.length > 10) {
      session.history = session.history.slice(-10);
      this.saveSession(session);
    }
  }

  // Create history snapshot
  createSnapshot() {
    return {
      url: window.location.href,
      state: window.history.state,
      title: document.title,
      timestamp: Date.now(),
      scrollPosition: this.getScrollPosition(),
      viewport: this.getViewport(),
    };
  }

  // Restore from snapshot
  restoreSnapshot(snapshot) {
    window.history.pushState(snapshot.state, snapshot.title, snapshot.url);

    document.title = snapshot.title;
    this.restoreScrollPosition({ _metadata: snapshot });
  }
}

// Usage
const advancedHistory = new AdvancedHistoryManager();

// Push state with metadata
advancedHistory.pushStateWithMetadata(
  { productId: 123 },
  'Product Details',
  '/products/123',
  {
    category: 'electronics',
    referrer: 'search',
  }
);

// Get history for UI
const historyEntries = advancedHistory.getHistoryEntries();
console.log('History entries:', historyEntries);

// Create and restore snapshots
const snapshot = advancedHistory.createSnapshot();
// Later...
advancedHistory.restoreSnapshot(snapshot);

URL Management

Advanced URL Handling

class URLManager {
  constructor() {
    this.baseUrl = window.location.origin;
    this.listeners = new Map();
  }

  // Parse current URL
  parseCurrentURL() {
    const url = new URL(window.location.href);

    return {
      href: url.href,
      origin: url.origin,
      protocol: url.protocol,
      host: url.host,
      hostname: url.hostname,
      port: url.port,
      pathname: url.pathname,
      search: url.search,
      hash: url.hash,
      params: this.parseSearchParams(url.searchParams),
      segments: this.parsePathSegments(url.pathname),
    };
  }

  // Parse search params
  parseSearchParams(searchParams) {
    const params = {};

    for (const [key, value] of searchParams) {
      if (params[key]) {
        if (Array.isArray(params[key])) {
          params[key].push(value);
        } else {
          params[key] = [params[key], value];
        }
      } else {
        params[key] = value;
      }
    }

    return params;
  }

  // Parse path segments
  parsePathSegments(pathname) {
    return pathname.split('/').filter((segment) => segment.length > 0);
  }

  // Build URL with params
  buildURL(path, params = {}, hash = '') {
    const url = new URL(path, this.baseUrl);

    // Add search params
    Object.entries(params).forEach(([key, value]) => {
      if (Array.isArray(value)) {
        value.forEach((v) => url.searchParams.append(key, v));
      } else if (value !== null && value !== undefined) {
        url.searchParams.set(key, value);
      }
    });

    // Add hash
    if (hash) {
      url.hash = hash.startsWith('#') ? hash : `#${hash}`;
    }

    return url.href;
  }

  // Update URL without navigation
  updateURL(updates = {}) {
    const current = this.parseCurrentURL();

    const newPath = updates.path || current.pathname;
    const newParams =
      updates.params !== undefined ? updates.params : current.params;
    const newHash = updates.hash !== undefined ? updates.hash : current.hash;

    const newURL = this.buildURL(newPath, newParams, newHash);

    // Use replaceState to update URL without navigation
    window.history.replaceState(window.history.state, document.title, newURL);

    // Notify listeners
    this.notifyListeners('update', {
      old: current,
      new: this.parseCurrentURL(),
    });
  }

  // Add query parameter
  addQueryParam(key, value) {
    const url = new URL(window.location.href);

    if (Array.isArray(value)) {
      value.forEach((v) => url.searchParams.append(key, v));
    } else {
      url.searchParams.append(key, value);
    }

    window.history.replaceState(window.history.state, document.title, url.href);
  }

  // Remove query parameter
  removeQueryParam(key) {
    const url = new URL(window.location.href);
    url.searchParams.delete(key);

    window.history.replaceState(window.history.state, document.title, url.href);
  }

  // Toggle query parameter
  toggleQueryParam(key, value) {
    const url = new URL(window.location.href);

    if (url.searchParams.has(key, value)) {
      url.searchParams.delete(key, value);
    } else {
      url.searchParams.append(key, value);
    }

    window.history.replaceState(window.history.state, document.title, url.href);
  }

  // Get relative path
  getRelativePath(url) {
    try {
      const parsed = new URL(url, this.baseUrl);

      if (parsed.origin !== this.baseUrl) {
        return null; // External URL
      }

      return parsed.pathname + parsed.search + parsed.hash;
    } catch (error) {
      return null;
    }
  }

  // Check if URL is external
  isExternal(url) {
    try {
      const parsed = new URL(url, this.baseUrl);
      return parsed.origin !== this.baseUrl;
    } catch (error) {
      return false;
    }
  }

  // Listen for URL changes
  onChange(callback) {
    const id = Date.now();
    this.listeners.set(id, callback);

    return () => this.listeners.delete(id);
  }

  // Notify listeners
  notifyListeners(type, data) {
    this.listeners.forEach((callback) => {
      callback({ type, ...data });
    });
  }

  // Create shareable URL
  createShareableURL(data = {}) {
    const url = new URL(window.location.href);

    // Encode complex data as base64
    if (Object.keys(data).length > 0) {
      const encoded = btoa(JSON.stringify(data));
      url.searchParams.set('share', encoded);
    }

    return url.href;
  }

  // Parse shareable URL
  parseShareableURL() {
    const url = new URL(window.location.href);
    const shareData = url.searchParams.get('share');

    if (shareData) {
      try {
        return JSON.parse(atob(shareData));
      } catch (error) {
        console.error('Failed to parse share data:', error);
        return null;
      }
    }

    return null;
  }
}

// Usage
const urlManager = new URLManager();

// Parse current URL
const urlInfo = urlManager.parseCurrentURL();
console.log('Current URL:', urlInfo);

// Build URL with params
const newUrl = urlManager.buildURL('/products', {
  category: 'electronics',
  sort: 'price',
  tags: ['new', 'featured'],
});
console.log('Built URL:', newUrl);

// Update URL without navigation
urlManager.updateURL({
  params: { ...urlInfo.params, page: 2 },
});

// Listen for changes
const unsubscribe = urlManager.onChange((event) => {
  console.log('URL changed:', event);
});

// Create shareable URL
const shareUrl = urlManager.createShareableURL({
  filters: { category: 'laptops' },
  view: 'grid',
});
console.log('Share URL:', shareUrl);

Browser Compatibility

History API Polyfill

class HistoryPolyfill {
  constructor() {
    this.setupPolyfill();
  }

  // Check if History API needs polyfill
  needsPolyfill() {
    return !(window.history && window.history.pushState);
  }

  // Setup polyfill
  setupPolyfill() {
    if (!this.needsPolyfill()) return;

    // Create history stack
    window.history = window.history || {};
    window.history._stack = [window.location.href];
    window.history._index = 0;

    // Implement pushState
    window.history.pushState = (state, title, url) => {
      // Update location hash
      window.location.hash = '#!' + url;

      // Add to stack
      window.history._stack.push(url);
      window.history._index++;

      // Store state
      window.history.state = state;

      // Update title
      if (title) {
        document.title = title;
      }
    };

    // Implement replaceState
    window.history.replaceState = (state, title, url) => {
      // Update location hash
      window.location.hash = '#!' + url;

      // Replace in stack
      window.history._stack[window.history._index] = url;

      // Store state
      window.history.state = state;

      // Update title
      if (title) {
        document.title = title;
      }
    };

    // Implement back
    window.history.back = () => {
      if (window.history._index > 0) {
        window.history._index--;
        const url = window.history._stack[window.history._index];
        window.location.hash = '#!' + url;
      }
    };

    // Implement forward
    window.history.forward = () => {
      if (window.history._index < window.history._stack.length - 1) {
        window.history._index++;
        const url = window.history._stack[window.history._index];
        window.location.hash = '#!' + url;
      }
    };

    // Implement go
    window.history.go = (delta) => {
      const newIndex = window.history._index + delta;

      if (newIndex >= 0 && newIndex < window.history._stack.length) {
        window.history._index = newIndex;
        const url = window.history._stack[window.history._index];
        window.location.hash = '#!' + url;
      }
    };

    // Setup hashchange listener
    window.addEventListener('hashchange', () => {
      this.handleHashChange();
    });
  }

  // Handle hash changes
  handleHashChange() {
    const hash = window.location.hash;

    if (hash.startsWith('#!')) {
      const url = hash.substring(2);

      // Trigger popstate event
      const event = new CustomEvent('popstate', {
        detail: {
          state: window.history.state,
        },
      });

      window.dispatchEvent(event);
    }
  }
}

// Initialize polyfill
const historyPolyfill = new HistoryPolyfill();

Best Practices

  1. Always handle popstate events

    window.addEventListener('popstate', (event) => {
      // Update UI based on state
      updateUI(event.state);
    });
    
  2. Store minimal state data

    // Good - store IDs and fetch data
    history.pushState({ productId: 123 }, '', '/product/123');
    
    // Avoid - storing large objects
    history.pushState({ product: largeProductObject }, '', '/product/123');
    
  3. Provide fallbacks for older browsers

    if (!window.history.pushState) {
      // Use hash-based routing
      window.location.hash = '#/products';
    }
    
  4. Handle initial page load

    // Check for existing state on load
    if (window.history.state) {
      restoreFromState(window.history.state);
    }
    

Conclusion

The History API provides essential tools for modern web applications:

  • State management for maintaining application context
  • URL manipulation without page reloads
  • Navigation control with proper back/forward support
  • Deep linking for shareable application states
  • SEO benefits with clean URLs
  • Better UX with instant navigation

Key takeaways:

  • Use pushState for new entries, replaceState for updates
  • Always handle popstate events
  • Store minimal state data
  • Provide proper fallbacks
  • Consider browser compatibility
  • Test navigation thoroughly

Master the History API to build sophisticated single-page applications with native-like navigation!