Skip to content

Commit cd2341e

Browse files
committed
update routing system + new article
1 parent 27c1162 commit cd2341e

File tree

15 files changed

+1928
-1341
lines changed

15 files changed

+1928
-1341
lines changed

package-lock.json

Lines changed: 1510 additions & 1218 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,26 @@
1818
},
1919
"dependencies": {
2020
"geist": "^1.4",
21-
"react": "^19.1",
22-
"react-dom": "^19.1",
21+
"react": "^19.2",
22+
"react-dom": "^19.2",
2323
"react-markdown": "^10.1",
24-
"react-syntax-highlighter": "^15.6"
24+
"react-syntax-highlighter": "^16.1"
2525
},
2626
"devDependencies": {
27-
"@eslint/js": "^9.32",
27+
"@eslint/js": "^9.39",
2828
"@tailwindcss/postcss": "^4.1",
2929
"@tailwindcss/vite": "^4.1",
30-
"@types/react": "^19.1",
31-
"@types/react-dom": "^19.1",
30+
"@types/react": "^19.2",
31+
"@types/react-dom": "^19.2",
3232
"@types/react-syntax-highlighter": "^15.5",
33-
"@vitejs/plugin-react": "^4.7",
34-
"eslint": "^9.32",
35-
"eslint-plugin-react-hooks": "^5.2",
33+
"@vitejs/plugin-react": "^5.1",
34+
"eslint": "^9.39",
35+
"eslint-plugin-react-hooks": "^7.0",
3636
"eslint-plugin-react-refresh": "^0.4",
37-
"globals": "^16.3",
37+
"globals": "^16.5",
3838
"tailwindcss": "^4.1",
39-
"typescript": "^5.8",
40-
"typescript-eslint": "^8.38",
41-
"vite": "^7.0"
39+
"typescript": "^5.9",
40+
"typescript-eslint": "^8.49",
41+
"vite": "^7.1"
4242
}
4343
}
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,326 @@
11
# Enhancing our hash routing system with parameters and typescript
2+
3+
This article is a follow-up to [this one](#posts#routing), written last year.
4+
To quickly recap, we built a routing system inspired by [`react-router`](https://reactrouter.com/), but based entirely on browser hashes. It's still being used on this website today.
5+
6+
However, the original version only supported static routes.
7+
With the addition of a blog system, I needed dynamic routes, so I wouldn't have to create a new React component for every single article.
8+
Also, since I migrated this site to TypeScript, this new version includes improvements and is fully written in TypeScript.
9+
10+
Now that we know where we want to go with this routing system, and still taking inspiration from React Router, we need a way to handle route parameters.
11+
12+
In a typical setup, you'd have route declarations like `/post/{slug}`.
13+
14+
That means the route for a blog post would look like `#post#{slug}`
15+
16+
And for this specific article, it would be `#post#routing-enhancement`
17+
18+
This means we’ll need a system to parse parameters from the URL, based on the route declaration.
19+
But also a way to generate routes dynamically based on those parameters.
20+
21+
## Parameter format
22+
23+
First of all, we need to create a `Param` type to represent our parameters, both for the route generation and parsing:
24+
25+
```tsx
26+
// context.ts
27+
export type Params = { [s: string]: string };
28+
```
29+
30+
## Generating a route
31+
32+
Before we parse a route to determine which article to display in a `Post` component, let’s first look at how to generate a route in the `Posts` component.
33+
34+
This component lists all articles, and for each one, it includes our custom `<Link />` element.
35+
36+
What we want is a simple way to transform our route template with `{param}` placeholders, to an usable url in the browser.
37+
Here’s the kind of result we’re aiming for:
38+
39+
```tsx
40+
// Posts.tsx
41+
export default function Posts() {
42+
const posts = usePosts();
43+
44+
return (
45+
<div className="grid">
46+
{posts.map(({ title, slug }) => (
47+
<Link to={generateRoute("#post#{slug}", { slug })}>
48+
{title}
49+
</Link>
50+
)}
51+
</div>
52+
);
53+
}
54+
```
55+
The code to convert a route template into a usable browser URL is pretty straightforward:
56+
we just replace each `{param}` with its actual value.
57+
58+
With the `Param` type, this also allows us to use `reduce` and `replaceAll` easily:
59+
60+
```ts
61+
// routing.ts
62+
import { type Params } from "./context";
63+
64+
export function generateRoute(route: string, params: Params = {}) {
65+
return Object.entries(params).reduce(
66+
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
67+
route,
68+
);
69+
}
70+
```
71+
72+
With this approach, a route like `#post#{id}-{slug}#{id}` would be transformed into the URL: `#post#3-test#3`
73+
using this call:
74+
```ts
75+
generateRoute("#post#{id}-{slug}#{id}", { id: 3, slug: "test" })
76+
```
77+
78+
## Parsing a route with parameters
79+
80+
Following the pattern used in React Router, we want to create a `useParams` hook that returns an object with all the parameters from the current URL.
81+
82+
For example, given the route template `#post#{slug}` and the current URL `#post#article1`, the hook should return: `{ slug: "article1" }`
83+
84+
So we can use it like this:
85+
86+
```tsx
87+
// Post.tsx
88+
export default function Post() {
89+
const { slug } = useParams();
90+
const { title, content } = usePost(slug);
91+
92+
return (
93+
<article>
94+
<h1>{title}</h1>
95+
<p>{content}</p>
96+
</article>
97+
);
98+
}
99+
```
100+
101+
But this introduces a new problem.
102+
103+
The `Post` component knows the current URL is `#post#article1`,
104+
but that could match multiple route patterns, like `#post#{slug}` or `#post#{slug}{id}`.
105+
So how can it know which one to use?
106+
107+
The answer is: **it shouldn’t**.
108+
It's not the responsibility of the `Post` component to figure that out.
109+
110+
Instead, we need to update our `Routes` and `Route` components so they also understand parameterized routes.
111+
112+
## Updating the old Route* components
113+
114+
Taking all of this into account, the `Route` component now needs to be smart enough to tell whether its pattern matches the current URL,
115+
and not just rely on something simple like `currentHash === myRouteHash`.
116+
117+
The `Routes` component, which manages the `RoutesContext`, also needs to expose more tools so both `Route` and the `useParams` hook can do their jobs properly.
118+
119+
To achieve this, the `RoutesContext` should no longer be just a simple string containing the current hash.
120+
Instead, it should become a proper payload that still includes the hash, but also:
121+
- the actual parameters extracted from the matching `Route` components
122+
- and a function that allows `Route` components to register those parameters
123+
124+
Here is our new context:
125+
126+
```tsx
127+
// context.ts
128+
import { createContext } from "react";
129+
130+
export type Params = { [s: string]: string };
131+
132+
interface RoutesContextType {
133+
hash: string;
134+
params: Params;
135+
addParams: (params: Params) => void;
136+
}
137+
138+
export const RoutesContext = createContext<RoutesContextType>({
139+
hash: "",
140+
params: {},
141+
addParams: () => {},
142+
});
143+
144+
```
145+
146+
Here is our new `Routes` component:
147+
148+
```tsx
149+
// Routes.tsx
150+
import { useCallback, useEffect, useMemo, useReducer, useState, type PropsWithChildren} from "react";
151+
import { RoutesContext, type Params } from "./context";
152+
153+
const ADD = "add";
154+
const RESET = "reset";
155+
156+
interface ReducerType {
157+
type: string;
158+
payload: Params;
159+
}
160+
161+
function reducer(
162+
state: Params = {},
163+
action: ReducerType = { type: "", payload: {} },
164+
): Params {
165+
switch (action.type) {
166+
case ADD:
167+
return { ...state, ...action.payload };
168+
case RESET:
169+
return {};
170+
default:
171+
return state;
172+
}
173+
}
174+
175+
export default function Routes({ children }: PropsWithChildren) {
176+
const [hash, setHash] = useState(window.location.hash);
177+
const [params, dispatch] = useReducer(reducer, {});
178+
179+
useEffect(() => {
180+
function hashChange() {
181+
setHash(window.location.hash);
182+
dispatch({ type: RESET });
183+
}
184+
185+
window.addEventListener("hashchange", hashChange);
186+
return () => {
187+
window.removeEventListener("hashchange", hashChange);
188+
};
189+
}, []);
190+
191+
const addParams = useCallback((params: Params) => {
192+
dispatch({ type: ADD, payload: params });
193+
}, []);
194+
195+
const payload = useMemo(
196+
() => ({ hash, params, addParams }),
197+
[hash, params, addParams],
198+
);
199+
200+
return (
201+
<RoutesContext.Provider value={payload}>{children}</RoutesContext.Provider>
202+
);
203+
}
204+
```
205+
206+
Here, we use a reducer to store all the parameters coming from the `Route` components that matched, and we define the `addParams` method so each `Route` component can report its parameters back up.
207+
208+
Don’t forget to clear the params array when the route changes, typically inside the function that listens to the `hashchange` event.
209+
210+
With this in place, the `useParams` hook becomes very straightforward:
211+
212+
```tsx
213+
// routing.ts
214+
import { useContext } from "react";
215+
import { RoutesContext, type Params } from "./context";
216+
217+
export function useParams(): Params {
218+
const { params } = useContext(RoutesContext);
219+
return params;
220+
}
221+
```
222+
223+
It simply returns the computed parameters from the `RoutesContext`.
224+
225+
Now we just need to update the `Route` component so it can parse the new route patterns, determine whether it matches, and send the matched parameters back to the context:
226+
227+
```tsx
228+
// Route.tsx
229+
import { useContext, useEffect, useMemo } from "react";
230+
import { RoutesContext, type Params } from "./context";
231+
232+
interface RouteProps {
233+
to?: string;
234+
element: React.ReactNode;
235+
}
236+
237+
const paramRegex = /\{[A-Za-z0-9]+\}/g;
238+
239+
export default function Route({ to = "", element }: RouteProps) {
240+
const { hash, addParams } = useContext(RoutesContext);
241+
242+
const regex = useMemo(
243+
(): string => to.replaceAll(paramRegex, "([A-Za-z0-9\\-]+)"),
244+
[to],
245+
);
246+
247+
const paramKeys = useMemo(
248+
(): string[] =>
249+
to.match(paramRegex)?.map((t) => t.replace("{", "").replace("}", "")) ??
250+
[],
251+
[to],
252+
);
253+
254+
const matches = hash.match(regex);
255+
256+
useEffect(() => {
257+
if (matches !== null && matches.includes(hash)) {
258+
matches.shift();
259+
addParams(
260+
paramKeys.reduce((acc: Params, key, index) => {
261+
acc[key] = matches[index];
262+
return acc;
263+
}, {}),
264+
);
265+
}
266+
}, [hash, regex, paramKeys, matches, addParams]);
267+
268+
if (matches === null || !matches.includes(hash)) {
269+
return null;
270+
}
271+
272+
return element;
273+
}
274+
```
275+
276+
We begin by processing the route pattern from `to` using a regex to extract all parameter names.
277+
These names are stored in `paramKeys`.
278+
279+
Then, using the same regex on the current `hash`, we check whether it matches and extract the corresponding parameter values.
280+
281+
Inside the `useEffect`, we send the matched parameters back to the context by merging the extracted keys with their values.
282+
283+
## Final result
284+
285+
You should now be able to use the entire system like this:
286+
287+
```tsx
288+
// App.tsx
289+
<Routes>
290+
<Route to="#posts" element={<Posts />} />
291+
<Route to="#post#{slug}" element={<Post />} />
292+
</Routes>
293+
294+
// Posts.tsx
295+
export default function Posts() {
296+
const posts = usePosts();
297+
298+
return (
299+
<div className="grid">
300+
{posts.map(({ title, slug }) => (
301+
<Link to={generateRoute("#post#{slug}", { slug })}>
302+
{title}
303+
</Link>
304+
)}
305+
</div>
306+
);
307+
}
308+
309+
// Post.tsx
310+
export default function Post() {
311+
const { slug } = useParams();
312+
const { title, content } = usePost(slug);
313+
314+
return (
315+
<article>
316+
<h1>{title}</h1>
317+
<p>{content}</p>
318+
</article>
319+
);
320+
}
321+
```
322+
323+
The hash-based routing system is still intentionally simple, but now it comes with much more practical functionality.
324+
There’s still plenty of room for improvement, and I may write a follow-up post if new needs arise or if the system evolves further.
325+
326+
For now, this version is lightweight, flexible, and perfectly suited for small projects, or for anyone who wants to understand how routing works under the hood without relying on a full framework.

src/assets/posts/routing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export default function Routes({ children }) {
7979

8080
Now, let's create our `Route` component. It serves to render a `React element` when the current hash matches its defined hash. Using the context we set up in the `Routes` component, it retrieves the current hash information. If there's a match, it returns the passed element; otherwise, it returns `null`. This is where you could implement more complex behaviors like wildcards `*` or fallbacks.
8181

82-
For simplicity, we'll keep it straightforward: if there's a match, we return the element. If the defined hash is null, we assume it's the homepage. If nothing matches, it just shows a blank page. (Maybe I'll add 404 support later on).
82+
For simplicity, we'll keep it straightforward: if there's a match, we return the element. If the defined hash is null, we assume it's the homepage. If nothing matches, it just shows a blank page.
8383

8484
```jsx
8585
// Route.jsx

src/components/Home.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ export default function Home() {
6464
</div>
6565
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10 mt-20">
6666
{Array.from({ length: breakpointCount }).map((_, index) => (
67-
<div key={index} className="flex flex-col gap-y-10">
67+
<div key={`div-${index}`} className="flex flex-col gap-y-10">
6868
{projects
6969
.filter((_, i) => i % breakpointCount === index)
7070
.map((p, i) => (
7171
<Project
72-
key={i}
72+
key={`project-${index}-${i}`}
7373
name={p.name}
7474
description={p.description}
7575
date={p.date}

src/components/ImageViewer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export default function ImageViewer() {
7171
>
7272
{images.map((image, i) => (
7373
<div
74-
key={i}
74+
key={`image-${i}`}
7575
className="h-full md:h-4/5 max-w-full shrink-0 snap-center"
7676
data-image-index={i}
7777
>

0 commit comments

Comments
 (0)