DEV Community

youngrok
youngrok

Posted on

# Revisiting URL-Based State Management in SPAs

Introduction

When developing Single Page Applications (SPAs), we frequently encounter challenges with global state management and URL synchronization. You've probably experienced the frustration of setting up filters, navigating to another page, then hitting the back button only to find all your previous filter settings have vanished.

Many developers attempt to solve this problem with complex global state libraries and synchronization logic, often making the code more complicated than it needs to be. However, sometimes using the URL itself as a state store can be a simpler and more effective solution.

Today, we'll explore how to eliminate global state libraries and manage state purely through URLs, and how modern browser technologies make this approach not only possible but elegant.

The Evolution of Web Development Approaches

Traditional Web (Multi-Page Application)

User Action → New URL → Server Request → Full Page Refresh
Enter fullscreen mode Exit fullscreen mode
  • ✅ URL perfectly represents state
  • ✅ Automatic browser history management
  • ✅ Natural support for bookmarking and sharing
  • ❌ Screen flashing, slow page transitions

SPA Approach

User Action → Client State Change → DOM Update
Enter fullscreen mode Exit fullscreen mode
  • ✅ Smooth user experience
  • ✅ Fast page transitions
  • ❌ Problems caused by URL and state separation

Finding the Modern Balance

User Action → URL Change → Automatic State Update → DOM Update
Enter fullscreen mode Exit fullscreen mode
  • ✅ Benefits of traditional web + Benefits of SPAs

The Secret Behind Flicker-Free URL Changes in SPAs

1. The Core Role of Browser History API

The biggest difference between traditional web and SPAs is the page loading mechanism:

// Traditional approach (screen flicker)
window.location.href = '/new-page'
// → Browser makes HTTP request to server → Full page refresh

// SPA approach (no screen flicker)  
history.pushState(null, '', '/new-page')
// → Only URL changes, no server request → JavaScript partially updates DOM
Enter fullscreen mode Exit fullscreen mode

Core History API methods:

// Add new entry to URL history (enables back navigation)
history.pushState(stateObj, title, url)

// Replace current history entry (back button goes to previous entry)
history.replaceState(stateObj, title, url) 

// Navigate back/forward
history.back()    // Same as history.go(-1)
history.forward() // Same as history.go(1)

// Detect back/forward navigation
window.addEventListener('popstate', (event) => {
  console.log('URL changed:', location.pathname)
  // Render component matching the new URL
})
Enter fullscreen mode Exit fullscreen mode

2. Simple SPA Router Implementation

class SimpleRouter {
  constructor() {
    this.routes = {}

    // Detect back/forward navigation
    window.addEventListener('popstate', () => {
      this.render()
    })

    // Intercept all link clicks
    document.addEventListener('click', (e) => {
      if (e.target.tagName === 'A') {
        e.preventDefault() // Prevent default page navigation
        const href = e.target.getAttribute('href')
        this.navigate(href)
      }
    })
  }

  // Navigation (no screen flicker!)
  navigate(path) {
    history.pushState(null, '', path) // Only change URL
    this.render() // Update screen
  }

  // Render component matching current URL
  render() {
    const path = window.location.pathname
    const component = this.routes[path] || this.routes['/404']

    // Only update DOM (no page refresh)
    document.getElementById('app').innerHTML = component()
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Next.js router.back() Internal Implementation

According to the Next.js App Router official documentation, router.back() "Navigate back to the previous route in the browser's history stack."[1]

// Next.js router.back() internally uses browser history stack
// Import from next/navigation
import { useRouter } from 'next/navigation'

function BackButton() {
  const router = useRouter()
  return (
    <button onClick={() => router.back()}>
      Go Back
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Next.js actively leverages native browser History API. The official documentation explicitly states: "Next.js allows you to use the native window.history.pushState and window.history.replaceState methods to update the browser's history stack without reloading the page. pushState and replaceState calls integrate into the Next.js Router"[2]

Real Problem and Solution Process

Let's use an online shopping mall's product filter feature as an example:

Step 1: Global State Only (Problems Arise)

// Initial approach: Using only Zustand
const useFilterStore = create((set) => ({
  selectedCategories: [],
  selectedBrands: [],
  sortOrder: 'desc',
  setSelectedCategories: (categories) => set({ selectedCategories: categories }),
}))
Enter fullscreen mode Exit fullscreen mode

Problems: State loss on back navigation, URLs can't be shared

Step 2: Global State + URL Sync (Increased Complexity)

// Intermediate approach: Bidirectional synchronization
const useFilterStore = create((set, get) => {
  const syncToUrl = (router) => {
    // State → URL synchronization logic
    const { selectedCategories, selectedBrands, sortOrder } = get()
    const params = new URLSearchParams()

    if (selectedCategories.length > 0) {
      params.set('categories', selectedCategories.join(','))
    }

    const newUrl = params.toString() 
      ? `/products?${params.toString()}` 
      : '/products'

    // Using History API - no screen flicker!
    router.replace(newUrl, { scroll: false })
  }

  return {
    selectedCategories: [],
    actions: {
      setSelectedCategories: (router, categories) => {
        set({ selectedCategories: categories })
        syncToUrl(router) // Synchronization needed every time
      },
      initFromUrl: (searchParams) => {
        // URL → state restoration logic
        const categories = searchParams.get('categories')
        if (categories) {
          set({ selectedCategories: categories.split(',') })
        }
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Problems: Complex synchronization logic, managing two state sources

Step 3: URL Only (Return to Simplicity)

// Final approach: URL as the state
import { useRouter } from 'next/navigation' // App Router
import { useSearchParams } from 'next/navigation'

export const useFilter = () => {
  const router = useRouter()
  const searchParams = useSearchParams()

  // Read state directly from URL (Single Source of Truth)
  const selectedCategories = useMemo(() => {
    const categories = searchParams.get('categories')
    return categories ? categories.split(',') : []
  }, [searchParams])

  const selectedBrands = useMemo(() => {
    const brands = searchParams.get('brands')  
    return brands ? brands.split(',') : []
  }, [searchParams])

  // URL update = state update
  const updateUrl = (newParams: Record<string, string | string[]>) => {
    const params = new URLSearchParams(searchParams.toString())

    Object.entries(newParams).forEach(([key, value]) => {
      if (Array.isArray(value)) {
        if (value.length > 0) {
          params.set(key, value.join(','))
        } else {
          params.delete(key)
        }
      } else if (value && value !== 'desc') { // Exclude default values
        params.set(key, value)
      } else {
        params.delete(key)
      }
    })

    const newUrl = params.toString() 
      ? `/products?${params.toString()}` 
      : '/products'

    // Flicker-free URL change (core of modern SPAs!)
    router.replace(newUrl, { scroll: false })
  }

  return {
    selectedCategories,
    selectedBrands,
    setSelectedCategories: (categories: string[]) => updateUrl({ categories }),
    setSelectedBrands: (brands: string[]) => updateUrl({ brands }),
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Approach Is Now Possible

1. Maturity of Browser Technologies

Completion of History API

  • pushState/replaceState: URL changes without screen refresh
  • popstate event: Detection of browser back/forward navigation
  • Cross-browser compatibility achieved

React 18 Performance Improvements

// React 18 automatically batches re-renders caused by URL changes
function ProductList() {
  const { selectedCategories } = useFilter()

  // URL change → searchParams change → useMemo recalculation → batched update
  const filteredProducts = useMemo(() => 
    products.filter(product => selectedCategories.includes(product.category))
  ), [selectedCategories])

  return <ProductGrid products={filteredProducts} />
}
Enter fullscreen mode Exit fullscreen mode

2. Framework Improvements

Next.js Integrated Approach

// Server and client components work the same way
// Server Component
async function DashboardPage({ searchParams }: {
  searchParams: { levels?: string, results?: string }
}) {
  const filters = {
    levels: searchParams.levels?.split(',') || [],
    results: searchParams.results?.split(',') || ['wrong']
  }

  const data = await fetchFilteredData(filters)
  return <DashboardView data={data} filters={filters} />
}

// Client component uses the same URL-based approach
'use client'
function FilterControls() {
  const { selectedLevels, setSelectedLevels } = useFilter()
  return (
    <select 
      value={selectedLevels} 
      onChange={(e) => setSelectedLevels([e.target.value])}
    >
      {/* options */}
    </select>
  )
}
Enter fullscreen mode Exit fullscreen mode

3. How router.back() Preserves State

// Scenario: Set filter → Navigate to other page → Back navigation
'use client'
import { useRouter } from 'next/navigation'

function ProductsPage() {
  const { selectedCategories, setSelectedCategories } = useFilter()
  const router = useRouter()

  // 1. User sets filter
  const handleFilterChange = (categories) => {
    setSelectedCategories(categories)
    // → URL changes to /products?categories=electronics,books
    // → Saved to browser history via history.replaceState()
  }

  // 2. Navigate to another page
  const goToProductDetail = () => {
    router.push('/products/123')
    // → Creates new history entry
    // → Previous: /products?categories=electronics,books
    // → Current: /products/123
  }

  // 3. When back button is clicked
  // router.back() → window.history.back() 
  // → Browser restores previous URL (/products?categories=electronics,books)
  // → useSearchParams automatically updates
  // → selectedCategories state automatically restored!
}
Enter fullscreen mode Exit fullscreen mode

Key Point: Since URL parameters are stored in browser history, when router.back() is called, the previous state is automatically restored.

Advantages of URL-Based State Management

1. True Utilization of the Web Platform

Leveraging features that browsers provide out of the box:

  • Back/Forward Navigation: Works automatically without additional code
  • Bookmarking: Save specific filter states as bookmarks
  • URL Sharing: Share filtered states with colleagues as-is
  • Refresh: State persistence
  • Deep Linking: Direct access to specific states from external sources

2. Dramatic Reduction in Development Complexity

// Before: Complex synchronization
Memory State  URL Synchronization
- useEffect to detect state changes
- URL parameter parsing logic  
- Initial timing management
- Handling synchronization failure cases

// After: Simple unidirectional flow
URL  State (Done!)
- Just read with useSearchParams
- Just update with router.replace
Enter fullscreen mode Exit fullscreen mode

3. Debugging and Testing Convenience

// Debugging: Current state visible just by looking at URL
/products?categories=electronics,books&brands=apple,samsung&sort=price

// Testing: Direct entry to specific states possible
test('Renders correctly in filtered state', () => {
  render(<ProductsPage />, { 
    router: { query: { categories: 'electronics', brands: 'apple' } }
  })
})
Enter fullscreen mode Exit fullscreen mode

Importance of Server Configuration

For SPAs to work properly, server configuration is also important:

# nginx configuration example
location / {
  try_files $uri $uri/ /index.html;
}
Enter fullscreen mode Exit fullscreen mode

This ensures that:

  • Even when directly accessing URLs like /products?categories=electronics&brands=apple
  • The server returns index.html
  • JavaScript loads and reads URL parameters to render the appropriate state

When Should You Use This Approach?

✅ States Suitable for URL-Based Management

  • Search/Filtering: States users want to share
  • Pagination: Should allow direct access to specific pages
  • Sort Options: Important for both SEO and user convenience
  • Tab States: Should allow bookmarking specific tabs

❌ Cases Where Global State Is Still Needed

  • User Authentication: Should not be exposed in URLs for security
  • Temporary UI State: Modal open/close, tooltip states
  • Form Intermediate Values: Temporary data before saving
  • Real-time Data: Real-time updates received via WebSocket

Performance and User Experience

Advantages

  • Fast Page Transitions: Immediate screen updates without server requests
  • Smooth Animations: CSS transition effects can be applied
  • State Persistence: Scroll position, selection state naturally maintained
  • Reduced Bundle Size: Elimination of global state libraries

Considerations

  • URL Length Limits: Avoid too many parameters
  • SEO Considerations: Maintain meaningful URL structures
  • Type Safety: URL parameters are always strings, so proper conversion is needed

Conclusion

If you're struggling with complex global state management, ask yourself: "Does this state really need to be in memory?" Using the URL itself as a state store can sometimes be the simplest and most effective solution.

Especially for states that users want to share—like filtering, searching, and sorting—storing them in the URL feels natural. Since browsers already provide a perfect history management system, why create complex synchronization logic?

It's time to reconsider the possibilities that the combination of modern frameworks and browser technologies offers.

References

[1] Next.js App Router Official Documentation - useRouter: https://nextjs.org/docs/app/api-reference/functions/use-router
[2] Next.js App Router Official Documentation - Linking and Navigating: https://nextjs.org/docs/app/getting-started/linking-and-navigating


If you have experience with URL-based state management or other SPA development patterns, please share in the comments!

Top comments (0)