Creating a Type-Safe Router for React Without Browser Navigation
In this post, we will build a typed routing navigation system for React apps without using external libraries. While most web applications benefit from full browser navigation with URL pathnames, there are specific scenarios where simpler routing solutions make sense. Desktop apps built with Electron, Chrome extensions, or simple browser widgets often don’t require traditional URL-based navigation. These applications typically bundle everything together without lazy loading, functioning more like traditional software.
There are also cases where your specific requirements aren’t easily addressed by existing routing libraries. Today, I’ll share a routing foundation I developed for such a situation, which I now use whenever traditional browser navigation isn’t needed. Though for most standard web applications, I recommend using TanStack Router, as it provides excellent typed navigation out of the box. If you’re curious about implementing a custom router for your specific needs, let’s explore how it works. You can find all the source code in this repository and watch the video walkthrough for a detailed explanation.
Defining View Types
To illustrate the implementation, let’s create a simple widget with three distinct views: a home screen with calculation state, a settings page, and a results view.
export type AppView =
| {
id: "home"
state: CalculationState
}
| {
id: "settings"
}
| {
id: "result"
state: CalculationState
}
export type AppViewId = AppView["id"]
The home screen allows you to select a calculation operation and generate two random numbers to work with. When you navigate to the result view, the application displays the calculated outcome based on your selected operation and inputs.
export const calculationOperations = [
"sum",
"subtract",
"multiply",
"divide",
] as const
export type CalculationOperation = (typeof calculationOperations)[number]
export type CalculationState = {
inputs: number[]
operation: CalculationOperation
}
When you launch the widget, your initial view displays the home screen with a predefined calculation state.
export const initialView: AppView = {
id: "home",
state: {
inputs: [5, 3],
operation: "sum",
},
}
Core Router Structure
In our router implementation, a view consists of both a path identifier and optional state data. This approach mirrors how traditional routing systems work, where data is typically passed between views using URL query parameters or browser history state. However, since we’re building a solution independent of browser navigation features, we’re implementing our own state management system.
export type View = {
id: string
state?: any
}
To define views with type safety, we’ll create a generic Views
type that receives a type parameter T
. In our application, this parameter will be the AppViewId
type—a union of ‘home’, ‘settings’, and ‘result’ strings—ensuring our router recognizes only valid view identifiers.
import { ComponentType } from "react"
export type Views = Record>
With our Views type defined, we’ll now map each route identifier to its corresponding React component, establishing the connection between our navigation system and the actual UI elements rendered for each view.
import { Views } from "@lib/navigation/Views"
import { HomeView } from "../HomeView"
import { ResultView } from "../ResultView"
import { SettingsView } from "../SettingsView"
import { AppViewId } from "./AppView"
export const views: Views = {
home: HomeView,
settings: SettingsView,
result: ResultView,
}
Navigation State Management
To enable backward navigation within our application, we’ll structure our navigation state around two key elements: an array that maintains the complete history of visited views, and a numeric index that tracks our current position within that history stack. This approach mimics how browser history works while giving us full control over the navigation experience.
import { getStateProviderSetup } from "@lib/ui/state/getStateProviderSetup"
import { View } from "./View"
type NavigationState = {
history: View[]
currentIndex: number
}
export const { useState: useNavigation, provider: NavigationProvider } =
getStateProviderSetup("Navigation")
The getStateProviderSetup
function is a utility from RadzionKit that generates a React Context provider and a custom hook for state management. In our navigation system, it creates a provider component and a hook to access navigation state throughout the application without writing boilerplate context code.
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { Dispatch, SetStateAction, createContext, useState } from "react"
import { ChildrenProp } from "../props"
import { ContextState } from "./ContextState"
import { createContextHook } from "./createContextHook"
export function getStateProviderSetup(name: string) {
const Context = createContext | undefined>(undefined)
type Props = ChildrenProp & { initialValue: T }
const Provider = ({ children, initialValue }: Props) => {
const [value, setValue] = useState(initialValue)
return (
{children}
)
}
return {
provider: Provider,
useState: createContextHook(
Context,
capitalizeFirstLetter(name),
(result): [T, Dispatch>] => [
result.value,
result.setValue,
],
),
}
}
Setting Up the Application
To implement our navigation system, we’ll wrap the application in a NavigationProvider
component that exposes the navigation state to all child components throughout the component tree. We initialize the navigation with currentIndex
set to 0 and a history
array containing only our previously defined initial view.
import { ActiveView } from "@lib/navigation/ActiveView"
import { NavigationProvider } from "@lib/navigation/state"
import { Layout } from "./Layout"
import { initialView } from "./navigation/AppView"
import { views } from "./navigation/views"
export const NavigationDemo = () => (
)
Creating a Layout Component
Next, we’ll implement a Layout
component to create a consistent container for all views in our application. This layout includes a back button in the top-left corner that remains disabled unless there’s both a previous view in the navigation history and the current view isn’t the first one in the sequence.
import { useNavigateBack } from "@lib/navigation/hooks/useNavigateBack"
import { useNavigation } from "@lib/navigation/state"
import { IconButton } from "@lib/ui/buttons/IconButton"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { interactive } from "@lib/ui/css/interactive"
import { hStack, vStack } from "@lib/ui/css/stack"
import { ChevronLeftIcon } from "@lib/ui/icons/ChevronLeftIcon"
import { SettingsIcon } from "@lib/ui/icons/SettingsIcon"
import { ChildrenProp } from "@lib/ui/props"
import { text } from "@lib/ui/text"
import { getColor } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { initialView } from "./navigation/AppView"
import { useAppNavigate } from "./navigation/hooks/useAppNavigate"
const Container = styled.div`
width: 320px;
${borderRadius.m}
${vStack()};
border: 1px solid ${getColor("mist")};
> * {
padding: 12px;
}
`
const Header = styled.div`
${hStack({
alignItems: "center",
justifyContent: "space-between",
})};
background: ${getColor("mist")};
`
const Content = styled.div`
${vStack({
gap: 20,
justifyContent: "space-between",
})};
min-height: 200px;
`
const Title = styled.div`
${interactive};
${text({
size: 20,
weight: 600,
})};
&:hover {
color: ${getColor("contrast")};
}
`
export const Layout = ({ children }: ChildrenProp) => {
const [{ history, currentIndex }] = useNavigation()
const goBack = useNavigateBack()
const navigate = useAppNavigate()
return (
}
title="Back"
onClick={goBack}
/>
navigate(initialView)}>Calculator
}
title="Settings"
onClick={() => navigate({ id: "settings" })}
/>
{children}
)
}
Implementing Navigation Hooks
Now let’s implement the hook that powers our back button functionality. The useNavigateBack
hook provides a clean abstraction for navigating to previous views in our history stack. It checks if backward navigation is possible by verifying the current position in our history array, and simply decrements the index when called.
import { useCallback } from "react"
import { useNavigation } from "../state"
export const useNavigateBack = () => {
const [, setState] = useNavigation()
return useCallback(() => {
setState((state) => {
if (state.currentIndex <= 0) {
return state
}
return {
...state,
currentIndex: state.currentIndex - 1,
}
})
}, [setState])
}
The useAppNavigate
hook provides type safety for navigation, ensuring you can only pass views that conform to the AppView
type definition. This prevents potential runtime errors by catching invalid navigation attempts during development.
import { useNavigate } from "@lib/navigation/hooks/useNavigate"
import { AppView } from "../AppView"
export function useAppNavigate() {
return useNavigate()
}
Creating a Type-Safe Navigation Hook
The generic useNavigate
hook powers our navigation system and provides the foundation for type-safe navigation. It manages history by tracking view entries and their associated state, handling both regular navigation and view replacement through a simple options parameter. When called with a view entry, it either adds a new history item or updates the current one, maintaining the appropriate index position for backward navigation.
import { updateAtIndex } from "@lib/utils/array/updateAtIndex"
import { useCallback } from "react"
import { useNavigation } from "../state"
import { View } from "../View"
type NavigateOptions = {
replace?: boolean
}
export function useNavigate() {
const [, setState] = useNavigation()
return useCallback(
(entry: T, options: NavigateOptions = {}) => {
const { id, state } = entry
const { replace } = options
setState((prev) => {
if (replace) {
return {
...prev,
history: updateAtIndex(prev.history, prev.currentIndex, () => ({
id,
state,
})),
}
}
const newHistory = [...prev.history, { id, state }]
return {
history: newHistory,
currentIndex: newHistory.length - 1,
}
})
},
[setState],
)
}
Rendering the Active View
With our navigation hooks in place, we now need a component that renders the appropriate UI based on the current navigation state. The ActiveView
component serves as the bridge between our navigation system and the actual view components. It extracts the current view ID from our navigation history at the current index position, looks up the corresponding component from our views mapping, and renders it to the screen.
import { useNavigation } from "./state"
import { Views } from "./Views"
type ActiveViewProps = {
views: Views
}
export const ActiveView = ({ views }: ActiveViewProps) => {
const [state] = useNavigation()
const View = views[state.history[state.currentIndex].id]
return
}
Building the View Components
To demonstrate how view state changes work, the HomeView
component provides two interactive elements: a radio input for selecting calculation operations and a button to generate new random inputs. When users interact with these controls, the application updates the state by pushing new view entries to the navigation history array, ensuring both the UI and internal state remain synchronized.
import { Button } from "@lib/ui/buttons/Button"
import { IconButton } from "@lib/ui/buttons/IconButton"
import { HStack } from "@lib/ui/css/stack"
import { RefreshIcon } from "@lib/ui/icons/RefreshIcon"
import { GroupedRadioInput } from "@lib/ui/inputs/GroupedRadioInput"
import { Text } from "@lib/ui/text"
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { randomIntegerInRange } from "@lib/utils/randomInRange"
import { calculationOperations } from "./CalculationState"
import { InputItem } from "./InputItem"
import { useAppNavigate } from "./navigation/hooks/useAppNavigate"
import { useAppViewState } from "./navigation/hooks/useAppViewState"
export const HomeView = () => {
const [state, setState] = useAppViewState<"home">()
const navigate = useAppNavigate()
return (
<>
{
setState((prev) => ({
...prev,
operation,
}))
}}
options={calculationOperations}
renderOption={capitalizeFirstLetter}
/>
}
title="Generate new inputs"
onClick={() => {
setState((prev) => ({
...prev,
inputs: prev.inputs.map(() => randomIntegerInRange(1, 10)),
}))
}}
/>
Inputs:
{state.inputs.map((input, index) => (
))}
>
)
}
Type-Safe State Management
To properly handle state in our typed navigation system, we need a mechanism that preserves type information across different views. When accessing a view’s state, we want TypeScript to know exactly which properties are available based on the view ID. The useAppViewState
hook provides this capability by leveraging TypeScript’s type system to extract the correct state type for each view. This ensures you can only access state properties that actually exist for the current view.
import { useViewState } from "@lib/navigation/hooks/useViewState"
import { AppView } from "../AppView"
type AppViewWithState = Extract
type AppViewWithStateId = AppViewWithState["id"]
type AppViewStateMap = {
[K in AppViewWithStateId]: Extract["state"]
}
export function useAppViewState() {
return useViewState()
}
Managing View State
The useAppViewState
hook builds on a more fundamental hook that manages state updates for any view type. The useViewState
hook handles the core functionality of accessing and updating state within our navigation system. It returns both the current view’s state and a setter function that maintains our navigation history, similar to React’s useState
pattern. When state changes occur, rather than simply updating the existing entry, it creates a new history item with the updated state, allowing users to navigate back to previous states while preserving the view’s identity.
import { Dispatch, SetStateAction, useCallback } from "react"
import { useNavigation } from "../state"
export function useViewState(): [T, Dispatch>] {
const [state, setState] = useNavigation()
const currentState = state.history[state.currentIndex].state as T
const setRouteState = useCallback(
(newState: SetStateAction) => {
setState((prev) => {
const id = prev.history[prev.currentIndex].id
const state =
typeof newState === "function"
? (newState as (prevState: T) => T)(
prev.history[prev.currentIndex].state,
)
: newState
const history = [...prev.history, { id, state }]
return {
...prev,
history,
currentIndex: history.length - 1,
}
})
},
[setState],
)
return [currentState, setRouteState]
}
Displaying Calculation Results
Clicking the “Calculate” button on the home screen triggers a sequence of navigation actions: a new view entry is pushed to the history array, the current index increments, and the ActiveView
component responds by rendering ResultView
. The result view then accesses the calculation state through the useAppViewState
hook to display the computation outcome.
import { HStack } from "@lib/ui/css/stack"
import { Text } from "@lib/ui/text"
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { match } from "@lib/utils/match"
import { CalculationState } from "./CalculationState"
import { InputItem } from "./InputItem"
import { useAppViewState } from "./navigation/hooks/useAppViewState"
const calculateResult = ({ inputs, operation }: CalculationState): number => {
return match(operation, {
sum: () => inputs.reduce((sum, num) => sum + num, 0),
subtract: () =>
inputs.reduce(
(result, num, index) => (index === 0 ? num : result - num),
0,
),
multiply: () => inputs.reduce((product, num) => product * num, 1),
divide: () =>
inputs.reduce(
(result, num, index) => (index === 0 ? num : result / num),
0,
),
})
}
export const ResultView = () => {
const [state] = useAppViewState<"result">()
const result = calculateResult(state)
return (
<>
Operation:
{capitalizeFirstLetter(state.operation)}
Inputs:
{state.inputs.map((input, index) => (
))}
Result:
{result.toFixed(2)}
>
)
}
Conclusion
This lightweight routing solution provides type-safe navigation for React applications that don’t require browser-based routing, making it particularly valuable for desktop apps, extensions, and widgets where you need full control over navigation state without URL dependencies.