Skip to content

How it works

Reactive Route has strong typing, well-thought-out redirect logic, and fault tolerance that are extremely hard to "break". For most scenarios, it is enough to define the configurations and use router.redirect; TS will guide you through the rest.

When working with the router, there are only two main structures:

Config

Contains all page logic

ts
user: {
  path: '/user/:id',
  params: {
    id: (value) => /^\d+$/.test(value)
  },
  query: {
    phone: (value) => value.length < 15
  },
  loader: () => import('./pages/user'),
  props: { foo: 'bar' },
  async beforeEnter({ nextState, redirect }) {
    await api.loadUser();

    if (!store.isLoggedIn) {
      return redirect({ 
        name: 'login', 
        query: { returnTo: nextState.name }
      });
    }
  },
  async beforeLeave({ nextState, preventRedirect }) {
    if (nextState.name === 'home') {
      return preventRedirect();
    }
  }
}
Config typing tests
ts
import { createConfigs } from '../../packages/core';

const loader = async () => ({ default: null });

const v = () => true;

createConfigs({
  dynamic: { path: '/:id/:tab', params: { id: v, tab: v }, loader },
  static: { path: '/', loader },
  notFound: { path: '/404', props: {}, loader },
  internalError: { path: '/500', props: {}, loader },

  // @ts-expect-error "params" must be "never"
  static1: { path: '/', loader, params: {} },

  // @ts-expect-error "params" are required
  dynamic1: { path: '/:id/:tab', loader },
  // @ts-expect-error "params.tab" and "params.id" are required
  dynamic2: { path: '/:id/:tab', params: {}, loader },
  // @ts-expect-error "params.tab" is required
  dynamic3: { path: '/:id/:tab', params: { id: v }, loader },
  // @ts-expect-error "params" validators must be functions
  dynamic4: { path: '/:id/:tab', params: { id: v, tab: '' }, loader },

  // @ts-expect-error duplicates are not allowed
  double1: { path: '/:id/:id', params: { id: v }, loader },
  // @ts-expect-error duplicates are not allowed
  double2: { path: '/:id/:id', loader },
  // @ts-expect-error duplicates are not allowed
  double3: { path: '/:id/:id/', params: { id: v }, loader },
  // @ts-expect-error duplicates are not allowed
  double4: { path: '/:id/:id/', loader },
  // @ts-expect-error duplicates are not allowed
  double5: { path: '/:id/:tab/:id', params: { id: v, tab: v }, loader },
  // @ts-expect-error duplicates are not allowed
  double6: { path: '/:id/:tab/:id', loader },
});

// @ts-expect-error internalError is required
createConfigs({ notFound: { path: '/404', loader } });

// @ts-expect-error notFound is required
createConfigs({ internalError: { path: '/500', loader } });

createConfigs({
  // @ts-expect-error "query" is not allowed
  notFound: { path: '/404', loader, query: {} },
  // @ts-expect-error "params" are not allowed
  internalError: { path: '/500', loader, params: { code: v } },
});

createConfigs({
  // @ts-expect-error "beforeLeave" is not allowed
  notFound: { path: '/404', loader, beforeLeave: () => undefined },
  // @ts-expect-error "beforeEnter" is not allowed
  internalError: { path: '/500', loader, beforeEnter: () => undefined },
});

State

Contains the current values of a specific Config

tsx
// pages/user/index.tsx

import { useRouter } from 'router';

export default function PageUser() {
  const { router } = useRouter();

  const pageState = router.state.user!;
  
  console.log(pageState);
  
  return (
    <>
      ID: {pageState.params.id}
      Phone: {pageState.query.phone}
    </>
  )
}

// console output (Observable object)
{
  name: 'user',
  params: { id: '9999' },
  query: { phone: '123456' }
}
tsx
// pages/user/index.tsx

import { useRouter } from 'router';

export default function PageUser() {
  const { router } = useRouter();

  const pageState = router.state.user!;

  console.log(pageState);

  return (
    <>
      ID: {pageState.params.id}
      Phone: {pageState.query.phone}
    </>
  )
}

// console output (Observable object)
{
  name: 'user',
  params: { id: '9999' },
  query: { phone: '123456' }
}
tsx
// pages/user/index.tsx

import { useRouter } from 'router';

export default function PageUser() {
  const { router } = useRouter();

  console.log(router.state.user);

  return (
    <>
      ID: {router.state.user!.params.id}
      Phone: {router.state.user!.query.phone}
    </>
  )
}

// console output (Observable object)
{
  name: 'user',
  params: { id: '9999' },
  query: { phone: '123456' }
}
vue
// pages/user/User.vue

<script lang="ts" setup>
  import { useRouter } from 'router';

  const { router } = useRouter();

  const pageState = router.state.user!;

  console.log(pageState);
</script>

<template>
  ID: {pageState.params.id}
  Phone: {pageState.query.phone}
</template>

<script lang="ts">
// console output (Observable object)
{
  name: 'user',
  params: { id: '9999' },
  query: { phone: '123456' }
}
</script>

INFO

The "non-null assertion" operator is safe if only one Config uses this page component.

State typing tests
ts
import { createConfigs, createRouter } from '../../packages/core';

const loader = async () => ({ default: null });

const v: (value: string) => boolean = () => true;

const adapters = {} as any;

const configs = createConfigs({
  static: { path: '/', loader },
  staticQuery: { path: '/', query: { q: v }, loader },
  dynamic: { path: '/:id', params: { id: v }, loader },
  dynamicQuery: {
    path: '/:id',
    params: { id: v },
    query: { q: v },
    loader,
  },
  notFound: { path: '/404', loader },
  internalError: { path: '/500', loader },
});

const router = createRouter({ configs, adapters });

// @ts-expect-error not existing name
router.state.unknown;

router.state.static!.name;
router.state.static!.params;
router.state.static!.query;
// @ts-expect-error "params" values are not available
router.state.static!.params.unknown;
// @ts-expect-error "query" values are not available
router.state.static!.query.unknown;

router.state.notFound!.name;
router.state.notFound!.params;
// @ts-expect-error "params" values are not available
router.state.notFound!.params.unknown;
router.state.notFound!.query;
// @ts-expect-error "query" values are not available
router.state.notFound!.query.unknown;

router.state.internalError!.name;
router.state.internalError!.params;
// @ts-expect-error "params" values are not available
router.state.internalError!.params.unknown;
router.state.internalError!.query;
// @ts-expect-error "query" values are not available
router.state.internalError!.query.unknown;

router.state.staticQuery!.name;
router.state.staticQuery!.params;
// @ts-expect-error "params" values are not available
router.state.staticQuery!.params.unknown;
router.state.staticQuery!.query.q;
// @ts-expect-error unknown "query" are not available
router.state.staticQuery!.query.unknown;

router.state.dynamic!.name;
router.state.dynamic!.params.id;
// @ts-expect-error unknown "params" are not available
router.state.dynamic!.params.unknown;
router.state.dynamic!.query;
// @ts-expect-error "query" values are not available
router.state.dynamic!.query.unknown;

router.state.dynamicQuery!.name;
router.state.dynamicQuery!.params.id;
router.state.dynamicQuery!.query.q;
// @ts-expect-error unknown "params" are not available
router.state.dynamicQuery!.params.unknown;
// @ts-expect-error unknown "query" are not available
router.state.dynamicQuery!.query.unknown;
Type Narrowing for State
ts
import { createConfigs, createRouter } from '../../packages/core';

const loader = async () => ({ default: null });

const v: (value: string) => boolean = () => true;

const adapters = {} as any;

const configs = createConfigs({
  static: { path: '/', loader },
  staticQuery: { path: '/', query: { q: v }, loader },
  dynamic: { path: '/:id', params: { id: v }, loader },
  dynamicQuery: {
    path: '/:id',
    params: { id: v },
    query: { q: v },
    loader,
  },
  notFound: { path: '/404', loader },
  internalError: { path: '/500', loader },
});

const router = createRouter({ configs, adapters });

const state = router.urlToState('');

// @ts-expect-error not existing name
state.name === 'unknown';

if (state.name === 'notFound') {
  state.name;
  state.params;
  // @ts-expect-error "params" values are not available
  state.params.unknown;
  state.query;
  // @ts-expect-error "query" values are not available
  state.query.unknown;
}

if (state.name === 'internalError') {
  state.name;
  state.params;
  // @ts-expect-error "params" values are not available
  state.params.unknown;
  state.query;
  // @ts-expect-error "query" values are not available
  state.query.unknown;
}

if (state.name === 'static') {
  state.name;
  state.params;
  state.query;
  // @ts-expect-error "params" values are not available
  state.params.unknown;
  // @ts-expect-error "query" values are not available
  state.query.unknown;
}

if (state.name === 'staticQuery') {
  state.name;
  state.params;
  // @ts-expect-error "params" values are not available
  state.params.unknown;
  state.query.q;
  // @ts-expect-error unknown "query" are not available
  state.query.unknown;
}

if (state.name === 'dynamic') {
  state.name;
  state.params.id;
  // @ts-expect-error unknown "params" are not available
  state.params.unknown;
  state.query;
  // @ts-expect-error "query" values are not available
  state.query.unknown;
}

if (state.name === 'dynamicQuery') {
  state.name;
  state.params.id;
  state.query.q;
  // @ts-expect-error unknown "params" are not available
  state.params.unknown;
  // @ts-expect-error unknown "query" are not available
  state.query.unknown;
}

This is a normalized structure in which params and query objects are always present. However, if they are not described in the corresponding configuration, their type will be Record<never, string>. This is done to protect runtime behavior, because a developer might add ts-ignore somewhere and, without default values, the code would crash with Cannot read property of undefined.

However, for the redirect mechanism it is more convenient to work with more strict types, and for this purpose StateDynamic was created, which completely forbids passing params and query if they were not present in the page configuration.

StateDynamic typing tests
ts
import { createConfigs, createRouter } from '../../packages/core';

const loader = async () => ({ default: null });

const v: (value: string) => boolean = () => true;

const adapters = {} as any;

const configs = createConfigs({
  static: { path: '/', loader },
  staticQuery: { path: '/', query: { q: v }, loader },
  dynamic: { path: '/:id', params: { id: v }, loader },
  dynamicQuery: {
    path: '/:id',
    params: { id: v },
    query: { q: v },
    loader,
  },
  notFound: { path: '/404', loader },
  internalError: { path: '/500', loader },
});

const router = createRouter({ configs, adapters });

await router.redirect({ name: 'static' });
await router.redirect({ name: 'staticQuery' });
await router.redirect({ name: 'staticQuery', query: { q: '' } });
await router.redirect({ name: 'dynamic', params: { id: '' } });
await router.redirect({ name: 'dynamicQuery', params: { id: '' } });
await router.redirect({
  name: 'dynamicQuery',
  params: { id: '' },
  query: { q: '' },
});

/** Not string values raise errors */

await router.redirect({
  // @ts-expect-error name must be a string
  name: 1,
});
await router.redirect({
  name: 'staticQuery',
  // @ts-expect-error "query" values must be strings
  query: { q: 1 },
});
await router.redirect({
  name: 'dynamic',
  // @ts-expect-error "params" values must be strings
  params: { id: 1 },
});
await router.redirect({
  name: 'dynamicQuery',
  params: { id: '' },
  // @ts-expect-error "query" values must be strings
  query: { q: 1 },
});
// @ts-expect-error "params" are required
await router.redirect({ name: 'dynamic' });
await router.redirect({
  name: 'dynamic',
  // @ts-expect-error "params" are incomplete
  params: {},
});

/** Irrelevant StateDynamic raise errors */

// @ts-expect-error name must be present
await router.redirect({});
// @ts-expect-error "name" must be a string key of configs
await router.redirect({ name: 'unknown' });
await router.redirect({
  name: 'static',
  // @ts-expect-error "params" are not allowed
  params: { id: '' },
});
await router.redirect({
  name: 'staticQuery',
  // @ts-expect-error "params" are not allowed
  params: { id: '' },
});
await router.redirect({
  name: 'dynamic',
  // @ts-expect-error extra "params" are not allowed
  params: { id: '', extra: '' },
});
await router.redirect({
  name: 'static',
  // @ts-expect-error "query" is not allowed
  query: { q: '' },
});
await router.redirect({
  name: 'dynamic',
  params: { id: '' },
  // @ts-expect-error "query" is not allowed
  query: { q: '' },
});
await router.redirect({
  name: 'staticQuery',
  // @ts-expect-error unknown "query" key
  query: { unknown: '' },
});
await router.redirect({
  name: 'dynamicQuery',
  params: { id: '' },
  // @ts-expect-error unknown "query" key
  query: { unknown: '' },
});

const state = router.urlToState('');

void router.redirect(state);

Decoding

The browser works with URLs in an encoded format, so Reactive Route has built-in encoding and decoding mechanisms. Let us look at the initialization process of the first redirect (if all validators are replaced with () => true):

ts
await router.init(`/user/with%20space?phone=and%26symbols`);

The router does not understand the string format, because it is absent from the structures described above.

For conversion, there is the router.urlToState method, which cleans the URL of unnecessary parts, decodes values, runs validators, and returns a State that the router understands, and the redirect is then called with it:

ts
await router.redirect({
  name: 'user',
  params: { id: 'with space' },
  query: { phone: 'and&symbols' }
})

Important

Validators receive decoded values for convenience when working with non-English URLs:

id: (value) => console.log(value) will print not with%20space, but with space

phone: (value) => console.log(value) will print not and%26symbols, but and&symbols

Redirect flow

  • the redirect reason is investigated. In this case, reason = 'new_config'
  • beforeLeave of the previous Config is executed (in this case there was none, so it is skipped)
  • beforeEnter of the next Config is executed, including any redirect chain
  • the js chunk is loaded (if code-splitting is enabled) with the component and other exports
  • the normalized State is written to the corresponding router.state[config.name], in this case router.state.user
  • if synchronization with the History API is enabled (by default it is enabled for the browser environment), native pushState / replaceState are called

No AI participated in the development. MIT License