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
- ✅ 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
- ✅ 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
- ✅ 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
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
})
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()
}
}
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>
)
}
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 }),
}))
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(',') })
}
}
}
}
})
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 }),
}
}
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} />
}
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>
)
}
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!
}
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
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' } }
})
})
Importance of Server Configuration
For SPAs to work properly, server configuration is also important:
# nginx configuration example
location / {
try_files $uri $uri/ /index.html;
}
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)