I was building a dashboard recently, a typical React application with multiple views and complex user permissions. I kept running into the same snag: my navigation logic was tangled up with my UI state. A user would complete an action, and I needed to send them to a new page, but the logic for that decision was buried deep inside a component. It felt messy. That’s when I started looking for a cleaner way to marry my application’s state with its navigation. The answer I found was surprisingly elegant: combining Zustand, a state management library I already loved for its simplicity, with React Router. This approach let me drive navigation from a central, predictable state, and I want to show you how it can clean up your own projects.
Think about a multi-step checkout process. The user moves from the cart, to shipping details, to payment. Traditionally, you might manage the current step with React state and use React Router’s useNavigate hook to change the URL. But what if you need to persist that progress? Or jump back to a specific step after a page refresh? By placing the current step and the user’s form data in a Zustand store, the navigation becomes a direct reflection of your state. The router simply listens and reacts.
So, how do we start? First, you need a store that holds not just your application data, but also the logic for navigation. Zustand makes this straightforward. Here’s a basic store for an authentication flow:
import { create } from 'zustand';
const useAuthStore = create((set, get) => ({
user: null,
isLoading: false,
login: async (credentials) => {
set({ isLoading: true });
// Simulate an API call
const user = await fakeAuthAPI(credentials);
set({ user, isLoading: false });
// Navigation logic is part of the store action
get().navigateToDashboard();
},
navigateToDashboard: () => {
// We'll get to how 'navigate' is available here
const navigate = get().navigate;
if (navigate) navigate('/dashboard');
},
navigate: null, // This will be set from a component
}));
Notice the navigate function in the store? It starts as null. This is the key link. We need to inject the actual navigation function from React Router into our store after the app initializes. We do this in a component, like our main App component or a layout component, using an effect.
import { useAuthStore } from './stores/authStore';
import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
function AppInitializer() {
const navigate = useNavigate();
const setNavigate = useAuthStore((state) => state.setNavigate);
useEffect(() => {
useAuthStore.setState({ navigate });
}, [navigate]);
return null; // This component doesn't render anything
}
Now, any action in our useAuthStore can trigger a navigation. When login succeeds, it calls navigateToDashboard(), which uses the injected function to send the user to /dashboard. The navigation is a direct consequence of the state change, not a separate command. Can you see how this centralizes your workflow logic?
This pattern shines in more complex scenarios. Imagine a content management system where a user’s permissions determine which pages they can see. You could have a store that holds the user’s role and a list of allowed routes. A protected layout component could then read this state and redirect unauthorized users before they even see a page.
// In a store
const useAppStore = create((set) => ({
userRole: 'editor',
allowedRoutes: ['/posts', '/media'],
// ...
}));
// In a ProtectedLayout component
import { useAppStore } from './stores/appStore';
import { Navigate, useLocation } from 'react-router-dom';
function ProtectedLayout({ children }) {
const { userRole, allowedRoutes } = useAppStore();
const location = useLocation();
if (!allowedRoutes.includes(location.pathname)) {
return <Navigate to="/not-authorized" replace />;
}
return children;
}
What happens when a user hits the browser’s back button? With this setup, your state and your URL can stay in sync. You can create a store that watches the URL via React Router’s useLocation and parses it to update an internal state representation. Conversely, changing that state can trigger a navigation to a new URL. This two-way sync is powerful for things like complex filters on a search page that you want to be bookmarkable.
One of my favorite uses is for persisting state across refreshes. Zustand can easily integrate with localStorage. By storing the essential navigation state—like the current step of a wizard or a product ID—you can have your app initialize, read this persisted state, and then programmatically navigate the user back to exactly where they left off. It creates a seamless experience that users appreciate.
The beauty of this integration is its testability. Because your navigation logic lives in a plain JavaScript store, you can test it without rendering a single React component or mocking a router. You just create the store, call actions like login, and assert that the navigate function was called with the right arguments. It separates your business logic from the UI framework, which is always a good architectural move.
Does this mean you should put all navigation in Zustand? Not necessarily. Simple links between pages are still best handled with <Link> components. This pattern is for driven navigation—when moving between pages is a direct result of a change in your application’s state. It turns navigation from an imperative command (navigate('/there')) into a declarative outcome (state.isLoggedIn === true).
I found that this approach cleaned up my code dramatically. Navigation stops being a side effect and becomes a core, manageable part of my application’s flow. It’s particularly useful for onboarding flows, auth guards, and any multi-step process. The next time you find yourself passing navigate props down through multiple components or writing complex useEffect hooks just to change a page, consider moving that responsibility to your state store.
Give this pattern a try in your next React project. Start small, perhaps with a simple login redirect. I think you’ll be surprised at how much cleaner your component logic becomes when navigation is state-driven. If you’ve tried similar integrations or have questions about specific edge cases, I’d love to hear about it in the comments below. Feel free to share this article with a teammate who might be wrestling with the same routing spaghetti code I was.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva