All ExamplesView source on GitHub
Routing
Client-side routing with URL parameters, nested routes, and navigation.
Routing
/
import { Array, Effect, Match as M, Option, Schema as S, pipe } from 'effect'
import { Route, Runtime } from 'foldkit'
import { Command } from 'foldkit/command'
import { Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { load, pushUrl, replaceUrl } from 'foldkit/navigation'
import { int, literal, r, slash } from 'foldkit/route'
import { evo } from 'foldkit/struct'
import { Url, toString as urlToString } from 'foldkit/url'
// ROUTE
const HomeRoute = r('Home')
const NestedRoute = r('Nested')
const PeopleRoute = r('People', { searchText: S.Option(S.String) })
const PersonRoute = r('Person', { personId: S.Number })
const NotFoundRoute = r('NotFound', { path: S.String })
export const AppRoute = S.Union(
HomeRoute,
NestedRoute,
PeopleRoute,
PersonRoute,
NotFoundRoute,
)
type HomeRoute = typeof HomeRoute.Type
type NestedRoute = typeof NestedRoute.Type
type PeopleRoute = typeof PeopleRoute.Type
type PersonRoute = typeof PersonRoute.Type
type NotFoundRoute = typeof NotFoundRoute.Type
export type AppRoute = typeof AppRoute.Type
const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute))
const nestedRouter = pipe(
literal('nested'),
slash(literal('route')),
slash(literal('is')),
slash(literal('very')),
slash(literal('nested')),
Route.mapTo(NestedRoute),
)
const peopleRouter = pipe(
literal('people'),
Route.query(
S.Struct({
searchText: S.OptionFromUndefinedOr(S.String),
}),
),
Route.mapTo(PeopleRoute),
)
const personRouter = pipe(
literal('people'),
slash(int('personId')),
Route.mapTo(PersonRoute),
)
const routeParser = Route.oneOf(
personRouter,
peopleRouter,
nestedRouter,
homeRouter,
)
const urlToAppRoute = Route.parseUrlWithFallback(routeParser, NotFoundRoute)
const people = [
{ id: 1, name: 'Alice Johnson', role: 'Designer' },
{ id: 2, name: 'Bob Smith', role: 'Developer' },
{ id: 3, name: 'Carol Davis', role: 'Manager' },
{ id: 4, name: 'David Wilson', role: 'Developer' },
{ id: 5, name: 'Eva Brown', role: 'Designer' },
]
const findPerson = (id: number) =>
Array.findFirst(people, person => person.id === id)
// MODEL
const Model = S.Struct({
route: AppRoute,
})
type Model = typeof Model.Type
// MESSAGE
const NoOp = m('NoOp')
const ClickedLink = m('ClickedLink', {
request: Runtime.UrlRequest,
})
const ChangedUrl = m('ChangedUrl', { url: Url })
const ChangedSearchInput = m('ChangedSearchInput', { value: S.String })
export const Message = S.Union(
NoOp,
ClickedLink,
ChangedUrl,
ChangedSearchInput,
)
export type Message = typeof Message.Type
// INIT
const init: Runtime.ApplicationInit<Model, Message> = (url: Url) => {
return [{ route: urlToAppRoute(url) }, []]
}
// UPDATE
const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
M.tagsExhaustive({
NoOp: () => [model, []],
ClickedLink: ({ request }) =>
M.value(request).pipe(
M.tagsExhaustive({
Internal: ({
url,
}): [Model, ReadonlyArray<Command<typeof NoOp>>] => [
model,
[pushUrl(urlToString(url)).pipe(Effect.as(NoOp()))],
],
External: ({
href,
}): [Model, ReadonlyArray<Command<typeof NoOp>>] => [
model,
[load(href).pipe(Effect.as(NoOp()))],
],
}),
),
ChangedUrl: ({ url }) => [
evo(model, {
route: () => urlToAppRoute(url),
}),
[],
],
ChangedSearchInput: ({ value }) => [
model,
[
replaceUrl(
peopleRouter({
searchText: Option.fromNullable(value || null),
}),
).pipe(Effect.as(NoOp())),
],
],
}),
)
// VIEW
const {
a,
article,
div,
h1,
h2,
header,
input,
keyed,
li,
main,
nav,
p,
search,
ul,
Class,
Href,
OnInput,
Placeholder,
Value,
} = html<Message>()
const navigationView = (currentRoute: AppRoute): Html => {
const navLinkClassName = (isActive: boolean) =>
`hover:bg-blue-600 font-medium px-3 py-1 rounded transition ${isActive ? 'bg-blue-700 bg-opacity-50' : ''}`
return nav(
[Class('bg-blue-500 text-white p-4 mb-6')],
[
ul(
[Class('max-w-4xl mx-auto flex gap-6 list-none')],
[
li(
[],
[
a(
[
Href(homeRouter()),
Class(navLinkClassName(currentRoute._tag === 'Home')),
],
['Home'],
),
],
),
li(
[],
[
a(
[
Href(peopleRouter({ searchText: Option.none() })),
Class(
navLinkClassName(
currentRoute._tag === 'People' ||
currentRoute._tag === 'Person',
),
),
],
['People'],
),
],
),
li(
[],
[
a(
[
Href(nestedRouter()),
Class(navLinkClassName(currentRoute._tag === 'Nested')),
],
['Nested'],
),
],
),
],
),
],
)
}
const homeView = (): Html =>
div(
[Class('max-w-4xl mx-auto px-4')],
[
h1([Class('text-4xl font-bold text-gray-800 mb-6')], ['Welcome Home']),
p(
[Class('text-lg text-gray-600 mb-4')],
[
'This is a routing example built with foldkit. Navigate using the links above to see different routes in action.',
],
),
p([Class('text-gray-600')], []),
],
)
const nestedView = (): Html =>
div(
[Class('max-w-4xl mx-auto px-4')],
[
h1(
[Class('text-4xl font-bold text-gray-800 mb-6')],
['Very Nested Route!'],
),
p(
[Class('text-lg text-gray-600')],
['You found the deeply nested route at /nested/route/is/very/nested'],
),
],
)
const peopleView = (searchText: Option.Option<string>): Html => {
const filteredPeople = Option.match(searchText, {
onNone: () => people,
onSome: query =>
Array.filter(
people,
person =>
person.name.toLowerCase().includes(query.toLowerCase()) ||
person.role.toLowerCase().includes(query.toLowerCase()),
),
})
return div(
[Class('max-w-4xl mx-auto px-4')],
[
h1([Class('text-4xl font-bold text-gray-800 mb-6')], ['People']),
search(
[Class('mb-6')],
[
input([
Value(Option.getOrElse(searchText, () => '')),
Placeholder('Search by name or role...'),
Class(
'w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
),
OnInput(value => ChangedSearchInput({ value })),
]),
],
),
p(
[Class('text-lg text-gray-600 mb-6')],
[
Option.match(searchText, {
onNone: () => 'Click on any person to view their details:',
onSome: query =>
`Searching for "${query}" - ${Array.length(filteredPeople)} results:`,
}),
],
),
ul(
[Class('space-y-3')],
Array.map(filteredPeople, person =>
li(
[Class('border border-gray-200 rounded-lg hover:bg-gray-50')],
[
a(
[
Href(personRouter({ personId: person.id })),
Class('block p-4 '),
],
[
div(
[Class('flex justify-between items-center')],
[
h2(
[Class('text-xl font-semibold text-gray-800')],
[person.name],
),
p([Class('text-gray-600')], [person.role]),
],
),
],
),
],
),
),
),
],
)
}
const personView = (personId: number): Html => {
const person = findPerson(personId)
return Option.match(person, {
onNone: () =>
div(
[Class('max-w-4xl mx-auto px-4')],
[
h2(
[Class('text-4xl font-bold text-red-600 mb-6')],
['Person Not Found'],
),
p(
[Class('text-lg text-gray-600 mb-4')],
[`No person found with ID: ${personId}`],
),
a(
[
Href(peopleRouter({ searchText: Option.none() })),
Class('text-blue-500 hover:underline'),
],
['← Back to People'],
),
],
),
onSome: person =>
div(
[Class('max-w-4xl mx-auto px-4')],
[
a(
[
Href(peopleRouter({ searchText: Option.none() })),
Class('text-blue-500 hover:underline mb-4 inline-block'),
],
['← Back to People'],
),
article(
[],
[
h2(
[Class('text-4xl font-bold text-gray-800 mb-6')],
[person.name],
),
div(
[Class('bg-gray-50 border border-gray-200 rounded-lg p-6')],
[
div(
[Class('grid grid-cols-2 gap-4')],
[
div(
[],
[
h2(
[
Class(
'text-sm font-medium text-gray-500 uppercase tracking-wide',
),
],
['ID'],
),
p(
[Class('text-lg text-gray-900 mt-1')],
[String(person.id)],
),
],
),
div(
[],
[
h2(
[
Class(
'text-sm font-medium text-gray-500 uppercase tracking-wide',
),
],
['Role'],
),
p(
[Class('text-lg text-gray-900 mt-1')],
[person.role],
),
],
),
],
),
],
),
],
),
],
),
})
}
const notFoundView = (path: string): Html =>
div(
[Class('max-w-4xl mx-auto px-4')],
[
h1(
[Class('text-4xl font-bold text-red-600 mb-6')],
['404 - Page Not Found'],
),
p(
[Class('text-lg text-gray-600 mb-4')],
[`The path "${path}" was not found.`],
),
a(
[Href(homeRouter()), Class('text-blue-500 hover:underline')],
['← Go Home'],
),
],
)
const view = (model: Model): Html => {
const routeContent = M.value(model.route).pipe(
M.tagsExhaustive({
Home: homeView,
Nested: nestedView,
People: ({ searchText }) => peopleView(searchText),
Person: ({ personId }) => personView(personId),
NotFound: ({ path }) => notFoundView(path),
}),
)
return div(
[Class('min-h-screen bg-gray-100')],
[
header([], [navigationView(model.route)]),
main(
[Class('py-8')],
[keyed('div')(model.route._tag, [], [routeContent])],
),
],
)
}
// RUN
const app = Runtime.makeApplication({
Model,
init,
update,
view,
container: document.getElementById('root')!,
browser: {
onUrlRequest: request => ClickedLink({ request }),
onUrlChange: url => ChangedUrl({ url }),
},
})
Runtime.run(app)