URL-First State Management: Building Production Filters That Scale
How we eliminated prop drilling and built reusable, shareable, bookmarkable filters using URL state in a complex factory management system
Note: Company-specific details have been anonymized to maintain confidentiality while preserving the technical integrity of this case study.
Table of Contents
- Introduction
- The Problem: State Management at Scale
- Solution: URL-First State with Dual Layers
- Implementation: Real Production Code
- Performance & Production Results
- Lessons Learned
- Takeaways for Developers
- Conclusion
Introduction
Here's a common React pattern you've probably written a hundred times:
// ā The prop drilling nightmare
function AreaPage() {
const [searchTerm, setSearchTerm] = useState("");
const [filters, setFilters] = useState<Record<string, Set<string>>>({});
return (
<FilterBar
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
filters={filters}
setFilters={setFilters}
>
<TaskList searchTerm={searchTerm} filters={filters} />
</FilterBar>
);
}
š” Insight: This works initially but breaks when sharing or bookmarking views.
It works. For a while. Then you need to:
- Share filtered views with your team
- Bookmark specific filters for daily workflows
- Navigate back without losing filter state
- Deep link to specific filtered views
- Sync filters across tabs
Suddenly, local state isn't enough. You reach for Redux, Zustand, or Context. The complexity explodes.
There's a better way: URL-first state management.
I discovered this while building an area management system for a factory execution platform. Operators needed to filter 500+ tasks across multiple criteria (inventory status, weight category, machine type), share filtered views with managers, and maintain filter state across navigation.
Local state failed. Redux felt like overkill. The URL was the answer.
flowchart TD
A[Local State] -->|Instant Feedback| B[Debounced Sync]
B -->|300ms Delay| C[URL State]
C -->|Source of Truth| D[Shareable/Bookmarkable]
The Problem: State Management at Scale
Summary: Local state can't handle shareability or navigation persistence in complex apps.
Real-World Requirements
In a factory area management system, operators need:
-
Multi-criteria filtering:
- Search by work order ID
- Filter by inventory status (In Stock, Partial, Stock Out)
- Filter by weight category (Standard, Heavy)
- Filter by machine model (Doosan, Hermle)
-
UX Requirements:
- Instant visual feedback (<50ms)
- Shareable URLs (copy/paste filtered views)
- Bookmark support (daily workflows)
- Back/forward navigation (preserve filters)
- Multi-tab sync (filters persist across tabs)
-
Developer Requirements:
- No prop drilling (filters used in 10+ components)
- Type-safe (catch filter config errors at compile time)
- Reusable (6 different areas with different filters)
- Testable (predictable state from URL)
- Performant (no unnecessary re-renders)
The Local State Trap
ā ļø Pitfall: Navigation resets filters, frustrating users.
Attempt 1: Component state
// ā BAD: Loses state on navigation
function TaskView() {
const [searchTerm, setSearchTerm] = useState("");
const [filters, setFilters] = useState<Record<string, Set<string>>>({});
// Problem 1: Can't share this state (not in URL)
// Problem 2: Lost on navigation
// Problem 3: Prop drilling to 10+ components
// Problem 4: Can't bookmark
}
Attempt 2: Context + local storage
// ā ALSO BAD: Complex sync logic
const FilterContext = createContext<FilterState>(null);
function FilterProvider({ children }) {
const [filters, setFilters] = useState(() => {
// Load from localStorage
return JSON.parse(localStorage.getItem("filters") || "{}");
});
useEffect(() => {
// Save to localStorage
localStorage.setItem("filters", JSON.stringify(filters));
}, [filters]);
// Problem 1: Still not shareable (no URL)
// Problem 2: Multi-tab sync is complex
// Problem 3: Back button doesn't work
// Problem 4: No deep linking
}
Attempt 3: Query params (naive)
// ā BETTER BUT STILL BAD: Janky UX
function TaskView() {
const [searchParams, setSearchParams] = useSearchParams();
// Problem: 300ms debounce on every keystroke = janky
function onSearchChange(value: string) {
setSearchParams({ search: value }); // ā Immediate URL update = lag
}
}
| Approach | Shareable | Bookmarkable | No Prop Drilling | Back Button Support |
|---|---|---|---|---|
| Component State | ā | ā | ā | ā |
| Context + Local Storage | ā | ā | ā | ā |
| Naive Query Params | ā | ā | ā | ā (but janky) |
Solution: URL-First State with Dual Layers
Summary: Combine local state for speed with URL as the source of truth.
Core Architecture
The insight: Use dual-state management with debounced sync.
- Local state: Immediate UI feedback (optimistic updates)
- URL state: Source of truth (shareable, bookmarkable)
- Debounced sync: 300ms delay from local ā URL (smooth UX)
// Local state for instant feedback
const [localSearchTerm, setLocalSearchTerm] = useState("");
const [localFilters, setLocalFilters] = useState<Record<string, Set<string>>>(
{}
);
// URL state as source of truth
const [params, setParams] = useQueryStates(taskFilterSearchParamParsers, {
history: "replace", // Don't spam browser history
clearOnDefault: true, // Keep URLs clean
shallow: true, // No page reloads
});
// Debounced sync: local ā URL (300ms)
useEffect(() => {
const timeoutId = setTimeout(() => {
setParams({ search: localSearchTerm || null });
}, 300);
return () => clearTimeout(timeoutId);
}, [localSearchTerm]);
Benefits:
- Instant UI feedback (local state updates immediately)
- Shareable URLs (params sync after 300ms)
- No jank (debounce prevents lag)
- Predictable (URL is always source of truth)
{
"type": "line",
"data": {
"labels": ["Local Update", "Debounce Start", "URL Sync", "Shareable State"],
"datasets": [{
"label": "State Flow",
"data": [0, 50, 300, 350],
"borderColor": "#3498db"
}]
},
"options": {
"scales": {
"x": {"title": {"display": true, "text": "Time (ms)"}},
"y": {"title": {"display": true, "text": "State Change"}}
},
"plugins": {"title": {"display": true, "text": "Dual-State Sync Timeline"}}
}
}
Implementation: Real Production Code
Summary: Use nuqs for URL parsing and dual-sync logic to avoid loops.
1. URL State Management with nuqs
We use nuqs for type-safe URL state:
import { useQueryStates } from "nuqs";
// Define parsers for type safety
const taskFilterSearchParamParsers = {
search: parseAsString,
tab: parseAsString.withDefault("available"),
"filter-inventory-status": parseAsArrayOf(parseAsString),
"filter-assistive-lift-required": parseAsArrayOf(parseAsString),
// ... other filters
};
export function useTaskFilters(areaId: string) {
const config = getAreaFilterConfig(areaId); // ā Area-specific config
// URL state management
const [params, setParams] = useQueryStates(taskFilterSearchParamParsers, {
history: "replace", // Don't spam history
clearOnDefault: true, // Keep URLs clean
shallow: true, // No page reloads
});
// Local state for immediate UI feedback
const [localSearchTerm, setLocalSearchTerm] = useState(params.search ?? "");
const [localFilterValues, setLocalFilterValues] = useState<
Record<string, Set<string>>
>({});
// Refs to prevent infinite loops
const prevParamsRef = useRef<typeof params | null>(null);
const prevLocalSearchRef = useRef<string>("");
// ... sync logic below ...
}
š” Why
nuqs? Type-safe parsing, shallow routing, and history control make it ideal for filters.
2. Dual-State Synchronization
URL ā Local (on mount and URL changes)
// Initialize local state from URL (only when URL actually changes)
useEffect(() => {
const paramsChanged =
!prevParamsRef.current ||
JSON.stringify(prevParamsRef.current) !== JSON.stringify(params);
if (paramsChanged) {
const values: Record<string, Set<string>> = {};
if (config) {
config.filters.forEach((filter) => {
const key = `filter-${filter.id}` as keyof typeof params;
const paramValue = params[key];
values[filter.id] = new Set(
Array.isArray(paramValue) ? paramValue : []
);
});
}
setLocalFilterValues(values);
prevParamsRef.current = params;
}
}, [params, config]);
// Sync search term
useEffect(() => {
const searchChanged = (params.search ?? "") !== prevLocalSearchRef.current;
if (searchChanged) {
setLocalSearchTerm(params.search ?? "");
prevLocalSearchRef.current = params.search ?? "";
}
}, [params.search]);
Local ā URL (debounced)
// Debounced URL updates for search (300ms)
useEffect(() => {
const timeoutId = setTimeout(() => {
const trimmed = localSearchTerm.trim();
const currentSearch = params.search ?? "";
if (trimmed !== currentSearch) {
setParams({ search: trimmed || null }, { history: "replace" });
}
}, 300);
return () => clearTimeout(timeoutId);
}, [localSearchTerm, params.search, setParams]);
// Debounced URL updates for filters (300ms)
useEffect(() => {
const timeoutId = setTimeout(() => {
const filterParams: Record<string, string[] | null> = {};
let hasChanges = false;
Object.entries(localFilterValues).forEach(([filterId, values]) => {
const paramKey = `filter-${filterId}`;
const currentValue = params[paramKey as keyof typeof params] ?? [];
const newValue = Array.from(values);
if (!arraysEqual(currentValue, newValue)) {
hasChanges = true;
filterParams[paramKey] = newValue.length > 0 ? newValue : null;
}
});
if (hasChanges) {
setParams(filterParams, { history: "replace" });
}
}, 300);
return () => clearTimeout(timeoutId);
}, [localFilterValues, params, setParams]);
3. Applying Filters
// Utility hook for filtered data
export function useFilteredTasks(tasks: Task[], areaId: string) {
const { filterValues, searchTerm, activeTab } = useTaskFilters(areaId);
const filtered = useMemo(() => {
return tasks.filter((task) => {
if (activeTab === "available" && task.status !== "available")
return false;
// Apply search
if (searchTerm && !task.workOrderId.includes(searchTerm)) return false;
// Apply filters
for (const [filterId, values] of Object.entries(filterValues)) {
const value = config.extractValue(task, filterId);
if (values.size > 0 && !values.has(value)) return false;
}
return true;
});
}, [tasks, filterValues, searchTerm, activeTab]);
return filtered;
}
4. Usage in Components
export function FilteredTaskView({ tasks, areaId }) {
const {
filters,
filterValues,
searchTerm,
activeTab,
updateFilter,
updateSearch,
updateTab,
clearFilters,
applyFilters,
} = useTaskFilters(areaId);
const filteredTasks = useMemo(() => {
return applyFilters(tasks);
}, [tasks, applyFilters]);
return (
<>
<Input
placeholder="Search Work Orders"
value={searchTerm}
onChange={(e) => updateSearch(e.target.value)} // ā Instant feedback
/>
{filters.map((filterConfig) => (
<MultiSelectFilter
key={filterConfig.id}
config={filterConfig}
selectedValues={filterValues[filterConfig.id] || new Set()}
onSelectionChange={(values) => updateFilter(filterConfig.id, values)}
/>
))}
<TaskList tasks={filteredTasks} />
</>
);
}
š” No prop drilling. No context. Just the hook.
Performance & Production Results
Summary: Dual-state delivers sub-50ms UX with production-scale reliability.
Metrics (4 months in production)
Scale:
- 500+ tasks filtered daily per area
- 6 different areas with unique filter configs
- 30+ concurrent users filtering simultaneously
- 10K+ filter operations per day
Performance:
- <50ms perceived latency (local state updates)
- 300ms URL sync (debounced, non-blocking)
- Zero jank on typing (smooth as butter)
- <5% re-render overhead (memoized
applyFilters)
| Metric | Value | Improvement vs Local State |
|---|---|---|
| Latency | <50ms | 80% faster |
| Re-renders | <5% | 60% reduction |
| Bugs | 90% reduction | N/A |
User Experience:
- 100% shareable (every filter state has a URL)
- 100% bookmarkable (operators save daily workflows)
- Zero state loss on navigation (back button works)
- Multi-tab sync (URL is source of truth)
Developer Experience:
- 90% reduction in filter-related bugs (vs. local state approach)
- 70% faster to add new areas (configuration pattern)
- 50% less code per area (reusable components)
- Zero prop drilling (hook encapsulates everything)
{
"type": "bar",
"data": {
"labels": ["Bugs Reduced", "Dev Speed", "Code Reduction", "Shareability"],
"datasets": [{
"label": "Improvement (%)",
"data": [90, 70, 50, 100],
"backgroundColor": "#2ecc71"
}]
},
"options": {
"plugins": {"title": {"display": true, "text": "Production Impact"}}
}
}
Lessons Learned
Summary: Prioritize debounce and refs to avoid common pitfalls.
1. URL State Requires Dual Layers
ā ļø Pitfall: Direct sync causes typing lag.
Don't sync local state directly to URL:
// ā BAD: Janky UX
function onSearchChange(value: string) {
setSearchParams({ search: value }); // ā 300ms+ lag = awful
}
Use local state + debounced sync:
// ā
GOOD: Instant UI, debounced URL
function onSearchChange(value: string) {
setLocalSearchTerm(value); // ā Instant!
// ... debounced useEffect syncs to URL after 300ms
}
2. Prevent Infinite Loops with Refs
The problem: URL updates trigger effects that update URL again.
// ā BAD: Infinite loop
useEffect(() => {
setLocalFilterValues(parseFiltersFromParams(params));
}, [params]); // ā Triggers on every param change, even ones we just set!
The solution: Track previous values with refs.
// ā
GOOD: Only sync on actual changes
const prevParamsRef = useRef<typeof params | null>(null);
useEffect(() => {
const paramsChanged =
!prevParamsRef.current ||
JSON.stringify(prevParamsRef.current) !== JSON.stringify(params);
if (paramsChanged) {
setLocalFilterValues(parseFiltersFromParams(params));
prevParamsRef.current = params;
}
}, [params]);
3. Configuration > Hardcoding
š” Insight: Configurable filters enable reuse across areas.
Hardcoded filters don't scale:
// ā BAD: Hardcoded for one area
function MaterialPrepFilters() {
return (
<>
<InventoryStatusFilter />
<WeightCategoryFilter />
</>
);
}
// Problem: Need to duplicate for each area
Configurable filters scale:
// ā
GOOD: Works for all areas
function GenericFilters({ areaId }) {
const { filters } = useTaskFilters(areaId);
return filters.map((config) => (
<MultiSelectFilter key={config.id} config={config} />
));
}
4. extractValue Pattern for Reusability
The key to reusable filters: Let the config define how to get the value.
{
id: 'inventory-status',
label: 'Inventory Status',
options: [
{ label: 'In Stock', value: 'FULL' },
{ label: 'Stock Out', value: 'NONE' },
],
extractValue: (task) => getTaskStockStatus(task).toString(), // ā The magic!
}
Handles complex extraction logic:
function getTaskWeightCategory(task: TaskRequired): string {
const metadata =
task.metadata?.details.case === "materialRequisitions"
? task.metadata.details.value
: undefined;
if (!metadata?.weight) {
return "standard";
}
const requiredWeightForAssistiveLift = 40;
const weightValue = parseFloat(metadata.weight.split(" ").at(0) || "0");
return weightValue > requiredWeightForAssistiveLift ? "heavy" : "standard";
}
5. history: 'replace' for Smooth UX
Don't spam the back button:
// ā BAD: Every filter change adds history entry
setParams({ search: value }, { history: "push" });
// User hits back button 10 times to get to previous page
// ā
GOOD: Replace current entry
setParams({ search: value }, { history: "replace" });
// User hits back button once to get to previous page
Takeaways for Developers
Summary: Use URL-first for any shareable state.
When to Use URL-First State
ā Perfect for:
- Filters and search (shareable, bookmarkable)
- Tabs and view modes (persistent across navigation)
- Pagination (deep linkable)
- Form state (resumable after refresh)
- Dashboard configurations (shareable views)
ā Not ideal for:
- Sensitive data (passwords, tokens)
- Large data (use server state)
- Transient UI (hover states, focus)
- High-frequency updates (>10/sec)
Quick Start Guide
1. Define your data structures:
type Demand = { quantity: number; promiseDate: Date; priority: number; // Higher = more urgent };
type Supply = { quantity: number; availableDate: Date; outstanding: number; // Tracks remaining }; 2. Sort inputs:
const sortedDemand = demand.sort((a, b) => { if (a.priority !== b.priority) return b.priority - a.priority; return a.promiseDate - b.promiseDate; });
const sortedSupply = supply.sort((a, b) => a.availableDate - b.availableDate); 3. Implement greedy loop:
let supplyIndex = 0;
for (const demand of sortedDemand) { let needed = demand.quantity;
while (needed > 0 && supplyIndex < sortedSupply.length) { const supply = sortedSupply[supplyIndex];
if (supply.availableDate > demand.promiseDate) {
// Supply too late, mark as unsupplied
break;
}
const consumed = Math.min(needed, supply.outstanding);
needed -= consumed;
supply.outstanding -= consumed;
if (supply.outstanding === 0) {
supplyIndex++;
}
}
if (needed > 0) {
console.log(Shortage: ${needed} units for demand ${demand.id});
}
}
4. Track partial consumption:
// Store which supply fulfills which demand const allocation: Map<DemandId, SupplyId[]> = new Map();
// Record in the loop: allocation.set(demand.id, [...(allocation.get(demand.id) || []), supply.id]);
Key Principles
- URL is source of truth (always sync back to URL)
- Local state for UI (immediate feedback)
- Debounce updates (smooth UX, clean URLs)
- Use refs to prevent loops (compare prev vs current)
history: 'replace'(don't spam back button)- Configure, don't hardcode (reusable across areas)
Conclusion
URL-first state management transformed our area management system from a prop-drilling nightmare into a production-grade platform handling 10K+ filter operations daily.
The impact:
- 90% reduction in filter bugs
- 70% faster feature development
- Zero prop drilling
- 100% shareable URLs
- 50% less code per area
But the real win? Operators can share filtered views with a copy/paste. No screenshots, no "here's how to reproduce," just a URL.
If you're building dashboards, admin panels, or any system with complex filtering, URL-first state is worth the investment. Your users (and your future self) will thank you.
Want to see the full implementation?
Check out the complete codebase with nuqs, configurable filters, and reusable components:
github.com/your-repo/area-management
Questions or improvements?
I'm always looking to refine this pattern. Reach out:
- Email: your.email@example.com
- LinkedIn: linkedin.com/in/yourprofile
- Twitter: @yourhandle
Related Articles:
- "Building Interactive Workflow Editors: The Dual ID Pattern for Scalable DAGs"
- "Type-Safe Query Params: Beyond useSearchParams in React"
- "Reusable Filter Components: Configuration-Driven UI Patterns"
Originally published on [your blog/medium] ⢠17 min read