Skip to content

Design URI-backed state middleware #1

Description

@ayden94

Summary

Add a framework-agnostic middleware for synchronizing selected state with the URI/query/hash.

This would allow view state such as search terms, filters, sorting, pagination, selected tabs, or wizard steps to be restored on refresh and shared via URL.

Background

The current middleware system already supports composition through pipe(...), and existing middleware such as persist(...) follows this pattern:

  1. Read an initial persisted value.
  2. Merge or apply it to the store.
  3. Use store.pushMiddleware(...) to write updates after state changes.

Current persist(...) support is storage-oriented only:

  • localStorage
  • sessionStorage
  • cookie

There is no URI/query/hash-backed state synchronization yet.

Proposed design

Introduce a separate middleware, tentatively named uriPersist(...), instead of extending persist(...).

const store = pipe(
  initialState,
  uriPersist({
    key: 'products',
    mode: 'query',
    history: 'replace',
    source: browserUrlSource(),
    partialize: (state) => ({
      q: state.q,
      page: state.page,
      sort: state.sort,
    }),
    merge: (state, uriState) => ({
      ...state,
      ...uriState,
    }),
  }),
);

Architecture

Use three layers:

  1. Core uriPersist middleware

    • Depends only on Store<T> and an abstract URL source.
    • Does not import React, Next.js, Vue, Vue Router, or browser globals directly.
  2. UrlSource abstraction

    • Encapsulates URL read/write/subscribe behavior.
    • Allows different routing environments to provide their own URL integration.
    type UrlSource = {
      read: () => URL | null;
      write: (url: URL, mode: 'replace' | 'push') => void;
      subscribe?: (listener: () => void) => () => void;
      writable?: () => boolean;
    };
  3. Sources/adapters

    • browserUrlSource() for general browser usage.
    • Optional router-specific adapters later:
      • nextUrlSource(...)
      • vueRouterUrlSource(...)
      • reactRouterUrlSource(...)

Core options

Potential option shape:

type UriPersistOptions<T, P> = {
  key: string;
  mode?: 'query' | 'hash';
  history?: 'replace' | 'push';
  source?: UrlSource;

  partialize?: (state: T) => P;
  merge?: (state: T, uriState: P) => T;

  serialize?: (state: P) => string;
  deserialize?: (value: string) => P;

  version?: number;
  migrate?: Array<(state: unknown) => unknown>;

  equals?: (a: P, b: P) => boolean;
};

Default behavior

  • Default mode: query
  • Default history: replace
  • Default source: browserUrlSource()
  • Default serialization: JSON + URL encoding
  • URL state should override or merge into initial state during client initialization.
  • Only selected state should be persisted via partialize(...).

SSR and framework compatibility

The core middleware should be SSR-safe:

  • Do not access window at module initialization time.
  • browserUrlSource() should check typeof window !== 'undefined' lazily.
  • On the server, URL read/write should be unavailable or no-op.

Framework support should come from the UrlSource boundary:

  • React, Svelte, Solid, Angular, and vanilla usage can use browserUrlSource().
  • Next.js can use either native History API support or a Next-specific adapter.
  • Vue Router apps should prefer a router adapter using router.push / router.replace to avoid router state drift.

Feedback-loop prevention

The implementation must prevent loops between store updates and URL updates:

store change -> URL write -> URL listener -> store change -> URL write -> ...

Possible safeguards:

  • Track isWritingUrl.
  • Track the last serialized URI value.
  • Skip URL-to-store sync if the incoming value equals the last written value.
  • Support custom equals(...) comparison.

History policy

Default to replace because most URL-backed view state should not create a new browser history entry on every keystroke or filter change.

Allow push for states where browser back/forward should navigate between state changes, such as wizard steps or tab navigation.

Security and size constraints

Document that URI-backed state must not contain:

  • access tokens
  • secrets
  • personal information
  • large objects
  • unstable or non-serializable data

This feature should be positioned for small, shareable view state rather than general persistence.

Suggested file structure

src/middleware/uriPersist/
  index.ts
  types.ts
  serializer.ts
  sources/
    browser.ts
  utils.ts

Potential exports:

export { uriPersist } from './uriPersist';
export { browserUrlSource } from './uriPersist/sources/browser';

Router adapters can be added later as separate entry points to avoid adding framework/router dependencies to the middleware core.

Acceptance criteria

  • uriPersist(...) works with the existing pipe(...) middleware composition API.
  • Selected state can be restored from query params.
  • Selected state can be restored from hash params.
  • State changes update the URL using replace by default.
  • push history mode is supported.
  • SSR does not crash due to eager window access.
  • Feedback loops between URL and store updates are prevented.
  • Serialization/deserialization can be customized.
  • Documentation explains that only small, non-sensitive view state should be stored in the URI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions