Skip to content

feat(router): Add useHistoryState hook for type-safe state management #3967

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 60 commits into
base: main
Choose a base branch
from

Conversation

naoya7076
Copy link
Contributor

@naoya7076 naoya7076 commented Apr 9, 2025

Proposal

Overview

This PR introduces the new useHistoryState hook and related functionality to enable type-safe history state management in TanStack Router.

Background

In the current implementation of TanStack Router, to pass type-safe state (History State) between routes that isn't part of the path or params, we need to define types globally. This leads to two main issues:

  1. Type pollution - types appear in places where they aren't needed
  2. Interface bloat - as more states are added, the global interface grows excessively

For example:

declare module '@tanstack/react-router' {
  interface HistoryState {
    name?: string;
    project?: MyProjectType;
  }
}

#284
When migrating from react-router to TanStack Router, developers want to be able to handle state in a type-safe manner, similar to how they could with react-router:

const history = useHistory();
history.replace({ pathname: '/', state: { name: 'Lily' }});
...
const location = useLocation();
// do something with location.state...

This PR addresses these issues by adding route-specific, type-safe history state management.

Changes

  • Added useHistoryState hook export in the main package index
  • Extended the FileRoute class to support state validation with TStateValidator generic parameter
  • Created a full example in examples/react/basic-history-state demonstrating usage patterns
  • The example shows how to:
    • Define state schemas using Zod
    • Pass state during navigation with Link and navigate

Key features of useHistoryState

  • Type-safe access to history state passed during navigation
  • Support for default values and optional properties
  • Ability to select specific state properties
  • Non-strict mode for accessing raw state object

Example usage

Users can define state validation on routes:

validateState: (input) =>
  z.object({
    color: z.enum(['white', 'red', 'green', 'blue']).default('white'),
    visited: z.boolean().default(false),
    message: z.string().optional(),
  }).parse(input),

Then use it in components:

// From route directly
const state = postRoute.useHistoryState()

// With select to extract specific values
const color = useHistoryState({
  from: '/posts/post',
  select: (state) => state.color
})

This commit introduces a new `validateState` function to handle state validation within the router. It supports various validation methods, including standard validation and parsing, and integrates with route options to validate state during routing.
This commit introduces the TStateValidator type across various routing components, enhancing the validation capabilities for state management. The changes include updates to the createFileRoute and Route classes, as well as adjustments in related files to support the new state validation feature.
This commit introduces the `useHistoryState` hook and related types across various routing components, improving state management capabilities. Updates include modifications to the Route and createRoute functions to support the new state handling feature.
This commit introduces a new section in the BaseTanStackRouterDevtoolsPanel to display state parameters when available.
This commit introduces the `useHistoryState` hook along with its associated types, enhancing the state management capabilities within the router. The new implementation allows for more flexible state handling and selection options, improving overall functionality.
This commit introduces new TypeScript types related to the `useHistoryState` hook, improving type safety and flexibility in state management within the router. The new types facilitate better state selection and handling options, enhancing the overall functionality of the routing system.
…roved state selection

This commit refactoring the state selection logic to utilize `useRouterState`.
…resolution

This commit modifies the `ResolveUseHistoryState` and ` UseHistoryStateResult` types
…ryState

This commit updates the `useHistoryState` hook to utilize `useLocation` for state management instead of `useRouterState`, improving the clarity and efficiency of state selection logic.
Copy link

nx-cloud bot commented Jun 14, 2025

View your CI Pipeline Execution ↗ for commit b81f26d.


☁️ Nx Cloud last updated this comment at 2025-06-14 19:47:21 UTC

@@ -1201,6 +1201,46 @@ export class RouterCore<
return [parentSearch, {}, searchParamError]
}
})()
const [preMatchState, strictMatchState, stateError]: [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is almost the same as search validation. can we move the identical parts into a reusable function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I unified the shared logic between validateState and validateSearch, including the filter step. Splitting it further would require a helper with too many parameters and little added value, so I’d prefer to keep it as is.

@@ -2962,6 +3033,33 @@ export function getInitialRouterState(
statusCode: 200,
}
}
function validateState(validateState: AnyValidator, input: unknown): unknown {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, can we move the validation of both state and search into a reusable function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this commit: the validation logic for state/search has been extracted to a common function.

@@ -89,6 +89,21 @@ export type ResolveSearchValidatorInput<TValidator> =
? ResolveSearchValidatorInputFn<TValidator['parse']>
: ResolveSearchValidatorInputFn<TValidator>

export type ResolveStateValidatorInputFn<TValidator> = TValidator extends (
Copy link
Contributor

@schiller-manuel schiller-manuel Jun 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the same type as for search, right? if yes, we should reuse
maybe just rename the search type to something generic?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 67f84d1 (#3967) &88c7126 (#3967) — both search and state now share one generic validator type.

select: (match: any) => {
const matchState = match.state;
const filteredState = Object.fromEntries(
Object.entries(matchState).filter(([key]) => !(key.startsWith('__') || key === 'key'))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the logic to filter internal state should be put into one single central location and be reused

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7847424 (#3967) and 799541f (#3967)

I pulled the internal-state filter into a single helper omitInternalKeys inside @tanstack/history and switched every call-site to use it.

@schiller-manuel
Copy link
Contributor

schiller-manuel commented Jun 14, 2025

can you please add type tests, similar to what we do for search?

also, what happens if I open up /posts directly and it has a state validator, but no state is present?
does this mean all state needs to be optional?

@naoya7076
Copy link
Contributor Author

naoya7076 commented Jun 22, 2025

@schiller-manuel

> can you please add type tests, similar to what we do for search?

added tests 481df10 (#3967) and 5315139 (#3967)

> what happens if I open up /posts directly and it has a state validator, but no state is present?

This behavior is same as how useSearch works.

Current Behavior

When you navigate directly to a /posts with a state validator but no state present:

  • The validator receives an empty object {}
  • Validation outcome depends on your validator implementation

Example

validateState: (input) =>
  z.object({
    example: z.string().default('default-example'),
    count: z.number().default(0),
    options: z.array(z.string()).default([]),
  }).parse(input),

Type System Handles This by strict

  • strict: true → required properties ({ page: number })
  • strict: false → optional properties ({ page?: number })

When strict: false, properties become optional.

Answer to Your Question

Does this mean all state needs to be optional?

No.You have the same options as with search params.

@hleekeeper
Copy link

Hi @naoya7076, thanks for putting this up together.

I have a question. How can one access to the route-specific history state type? For example, currently to access the type of history state, the HistoryState type is used. But this is just an aggregation of all possible history states. But let say I want to find the history state type of a specific path /example, would it be possible to do something like HistroyState['/example']?

@naoya7076
Copy link
Contributor Author

naoya7076 commented Jun 24, 2025

@hleekeeper

Currently, This PR(useHistoryState) doesn't support accessing route-specific history state types like
HistoryState['/example'].

The only available approach currently is to manually define the type:

type ExampleRouteState = {
  color: string
  // Same structure as defined in validateState
}

You're correct that the current HistoryState is an aggregation of all route states, and there's no
type-level API to extract specific route state types

@hleekeeper
Copy link

@hleekeeper

Currently, This PR(useHistoryState) doesn't support accessing route-specific history state types like HistoryState['/example'].

The only available approach currently is to manually define the type:

type ExampleRouteState = {
  color: string
  // Same structure as defined in validateState
}

You're correct that the current HistoryState is an aggregation of all route states, and there's no type-level API to extract specific route state types

I guess we can access the route-specific history state type by RouterType['routeTree']['/example']['types']['fullStateSchema']? But I may be wrong. I'll check it out when the PR is merged and get released. Anyway, thanks for the great work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants