Solid Router
@shadow-js/router is built around a router instance and route objects, not around <Router> and <Route> elements.
@shadow-js/router is built around a router instance and route objects, not around <Router> and <Route>
elements. Layouts are routes with a component and an <Outlet />; data comes from a route loader. The core
(matching, history, navigation state machine) is in @shadow-js/router/core.
Solid Router (not executed):
tsx
import { A, Route, Router, useNavigate, useParams, useSearchParams, createAsync, query } from "@solidjs/router";
const getPost = query(async (id) => (await fetch(`/api/posts/${id}`)).json(), "post");
function Post() {
const params = useParams();
const post = createAsync(() => getPost(params.id));
const [search, setSearch] = useSearchParams();
return <h1>{post()?.title}</h1>;
}
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/posts/:id" component={Post} preload={({ params }) => getPost(params.id)} />
<Route path="*404" component={NotFound} />
</Router>;
ShadowJS:
src/app-routes.tsxtsx
import {
A,
createMemoryHistory,
createRouter,
defineRoute,
notFound,
Outlet,
redirect,
useLoaderData,
useLocation,
useNavigate,
useNavigation,
useParams,
useSearch,
} from "@shadow-js/router";
import { loaderCache } from "@shadow-js/router/loaders";
import { notFoundUi } from "@shadow-js/router/not-found";
import { searchValidation } from "@shadow-js/router/search";
import { Show } from "@shadow-js/web";
export interface Post {
id: string;
title: string;
}
const posts: Record<string, Post> = {
"1": { id: "1", title: "First" },
"2": { id: "2", title: "Second" },
};
/** Ids the loader ran for, so the tests can count loads. */
export const loads: string[] = [];
async function loadPost(id: string): Promise<Post> {
loads.push(id);
const found = posts[id];
if (found === undefined) throw notFound();
return found;
}
function Layout() {
const navigation = useNavigation();
return (
<div>
<nav>
<A to="/" end activeClass="active">
Home
</A>{" "}
<A to="/posts/:id" params={{ id: "2" }}>
Post 2
</A>{" "}
<A to="/posts" search={{ page: 1 }}>
Posts
</A>
</nav>
<Show when={navigation.pending}>
<p>loading</p>
</Show>
<Outlet />
</div>
);
}
function Home() {
return <h1>Home</h1>;
}
function PostPage() {
const params = useParams({ from: "/posts/:id" });
const post = useLoaderData<Post>({ from: "/posts/:id" });
return (
<h1>
{post()?.title} ({params.id})
</h1>
);
}
function PostList() {
const search = useSearch<{ page: number }>({ from: "/posts" });
const location = useLocation();
const go = useNavigate();
return (
<div>
<p>page {search.page}</p>
<button type="button" onClick={() => go(location.pathname, { search: { page: search.page + 1 } })}>
Next page
</button>
</div>
);
}
const home = defineRoute("/", { component: Home });
const post = defineRoute("/posts/:id", {
loader: ({ params }) => loadPost(params.id),
component: PostPage,
notFound: () => <p>No such post</p>,
});
const list = defineRoute("/posts", {
validateSearch: (search) => ({ page: Number(search["page"] ?? 1) }),
component: PostList,
});
const legacy = defineRoute("/old", {
beforeLoad: () => {
throw redirect("/");
},
});
const missing = defineRoute("/*", { component: () => <p>Page not found</p> });
const root = defineRoute("", { id: "root", component: Layout, children: [home, post, list, legacy, missing] });
export function createApp(url: string) {
return createRouter({
routes: [root],
history: createMemoryHistory({ initialEntries: [url] }),
features: [loaderCache(), notFoundUi(), searchValidation()],
});
}
| Solid Router | ShadowJS | What differs |
|---|---|---|
<Router root={Layout}> |
createRouter({ routes, history?, base?, ... }) and <RouterProvider router={router}><Outlet /></RouterProvider> |
The router is a value you create. routes are defineRoute objects; a layout is a route (usually pathless: defineRoute("", { id, component, children })). Without a history option the browser history of window is used, or memory history when there is no window. |
<HashRouter>, <MemoryRouter>, <StaticRouter url> |
createRouter({ history: createHashHistory() }), createMemoryHistory({ initialEntries }) |
createBrowserHistory is also exported. For SSR use memory history and await router.ready() before rendering. |
<Route path component preload /> |
defineRoute(path, { component, loader, beforeLoad, children, ... }) |
There is no <Route> element and no array of paths per route. props.children of a layout becomes <Outlet />. |
/users/:id, /users/:id?, *splat, *404 |
/users/:id, /users/:id?, /files/*rest or * |
A splat is the last segment and captures the rest of the path under its name (* when bare). Match filters (matchFilters) do not exist. |
<A href end activeClass inactiveClass> |
<A to end activeClass params search hash replace state preload> |
to (or href) takes a route pattern with params or a path. activeClass is added while active and aria-current="page" is set on an exact match. No inactiveClass, no noScroll. preload is "intent" (default), "viewport" or false. |
<Navigate href /> |
<Navigate to params search hash replace /> |
Navigates when rendered, in the browser only. For route-dependent redirects throw redirect() from beforeLoad or loader. |
useParams() |
useParams({ from? }) |
A read-only view with one memo per key: a reader of id does not re-run when slug changes. |
useSearchParams() |
useSearchParams(validate?) from @shadow-js/router (returns [read, set]) |
validate types the query. set({ key: value }) merges the keys into the current query, undefined removes a key, and setters in one tick become one navigation. set(prev => ...) takes a function. { replace: true } replaces the entry. |
useNavigate() |
useNavigate() |
(to, { replace, state, params, search, hash }) returns a promise of the outcome (success, notFound, ...); it never throws for a redirect or a canceled navigation. No scroll or resolve option. |
useLocation() |
useLocation() |
{ pathname, search, hash }, reactive per property. No query, state or key. |
useMatch(() => path) |
useMatch({ from }) |
Returns an accessor of the rendered match for a route id (the route's path by default), or undefined. It does not test an arbitrary pattern. |
useCurrentMatches() |
useMatches() |
Accessor of the rendered chain, outermost first. |
useIsRouting() |
useNavigation() |
{ pending, status, location }. pending becomes true after pendingMs (default 1000 ms) and stays true for at least pendingMinMs (500 ms). Route-level pending components exist too. |
useBeforeLeave(cb) |
useBeforeLeave(listener), useBlocker(shouldBlock) from @shadow-js/router/blockers (with blockers() in features) |
event.preventDefault() keeps the user on the page; useBlocker returns status(), proceed() and reset(). In-app transitions only, not tab close. |
preload, query, createAsync |
loader({ params, search, context, location, intent, signal }) and useLoaderData() |
useLoaderData returns an accessor. Loaders of all matched routes run in parallel after beforeLoad chain. The result is cached per route and loaderDeps. staleReload chooses blocking or background refresh. |
revalidate(key) |
router.invalidate(filter?) |
Marks loader data stale (all, or by id) and reloads the current location. |
<A preload> / usePreloadRoute |
<A preload> / router.preload(to, options) |
Fills the cache without navigating. |
redirect() |
redirect(to) |
Thrown from beforeLoad or loader. |
action, useSubmission, <form action> |
none | Use onSubmit, an async function, useOptimistic, router.invalidate(). |
| error and 404 handling | route options error, notFound, pending, head |
error({ error, reset }) replaces the route; notFound() thrown from a loader renders the nearest route's notFound. |
base |
base option |
Matched on a segment boundary. |
| scroll restoration | scrollRestoration() feature (@shadow-js/router/scroll) |
Off unless it is in createRouter({ features }); it only acts when the router has a window. |
SSR (url, dehydrate) |
createMemoryHistory({ initialEntries: [url] }), router.ready(), router.dehydrate(), router.hydrate(data), serializeHydrationData, parseHydrationData |
Loader data travels in a JSON-serializable object. |