Skip to main content
All Examples

Route Transitions

A live log shows which Transition helper handles each navigation. Entering the gallery loads its catalog once, staying on a painting refetches only when its id changes, and leaving the studio saves a draft.

Routing
Transitions
Commands
/
import {
  Array,
  Duration,
  Effect,
  Match as M,
  Option,
  Schema as S,
  pipe,
} from 'effect'
import { Command, Runtime, Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { m } from 'foldkit/message'
import { UrlRequest, load, pushUrl } from 'foldkit/navigation'
import { Transition } from 'foldkit/route'
import { ts } from 'foldkit/schema'
import { evo } from 'foldkit/struct'
import { Url, toString as urlToString } from 'foldkit/url'

import { type Painting, findPaintingWithIndex, paintings } from './data'
import {
  AppRoute,
  galleryRouter,
  homeRouter,
  paintingRouter,
  studioRouter,
  urlToAppRoute,
} from './route'

export {
  AppRoute,
  GalleryRoute,
  HomeRoute,
  NotFoundRoute,
  PaintingRoute,
  StudioRoute,
} from './route'

const CATALOG_LATENCY = Duration.millis(600)
const PAINTING_LATENCY = Duration.millis(400)
const SAVE_LATENCY = Duration.millis(300)
const MAX_LOGGED_TRANSITIONS = 20

// MODEL

export const CatalogStatus = S.Literals(['Idle', 'Loading', 'Ready'])
export type CatalogStatus = typeof CatalogStatus.Type

export const PaintingIdle = ts('PaintingIdle')
export const PaintingLoading = ts('PaintingLoading', { paintingId: S.Number })
export const PaintingReady = ts('PaintingReady', { paintingId: S.Number })

export const PaintingStatus = S.Union([
  PaintingIdle,
  PaintingLoading,
  PaintingReady,
])
export type PaintingStatus = typeof PaintingStatus.Type

export const LoggedTransition = S.Struct({
  sequenceNumber: S.Number,
  maybePreviousRoute: S.Option(AppRoute),
  nextRoute: AppRoute,
})
export type LoggedTransition = typeof LoggedTransition.Type

export const Model = S.Struct({
  route: AppRoute,
  transitionLog: S.Array(LoggedTransition),
  catalogStatus: CatalogStatus,
  paintingStatus: PaintingStatus,
  studioDraft: S.String,
  maybeSavedDraft: S.Option(S.String),
})
export type Model = typeof Model.Type

// MESSAGE

export const CompletedNavigateInternal = m('CompletedNavigateInternal')
export const CompletedLoadExternal = m('CompletedLoadExternal')
export const ClickedLink = m('ClickedLink', { request: UrlRequest })
export const ChangedUrl = m('ChangedUrl', { url: Url })
export const SucceededLoadCatalog = m('SucceededLoadCatalog')
export const SucceededLoadPainting = m('SucceededLoadPainting', {
  paintingId: S.Number,
})
export const UpdatedStudioDraft = m('UpdatedStudioDraft', { value: S.String })
export const SucceededSaveDraft = m('SucceededSaveDraft', { draft: S.String })

export const Message = S.Union([
  CompletedNavigateInternal,
  CompletedLoadExternal,
  ClickedLink,
  ChangedUrl,
  SucceededLoadCatalog,
  SucceededLoadPainting,
  UpdatedStudioDraft,
  SucceededSaveDraft,
])
export type Message = typeof Message.Type

// COMMAND

const NavigateInternal = Command.define('NavigateInternal', {
  args: { url: S.String },
  messages: [CompletedNavigateInternal],
  execute: ({ url }) =>
    pushUrl(url).pipe(Effect.as(CompletedNavigateInternal())),
})

const LoadExternal = Command.define('LoadExternal', {
  args: { href: S.String },
  messages: [CompletedLoadExternal],
  execute: ({ href }) => load(href).pipe(Effect.as(CompletedLoadExternal())),
})

export const LoadCatalog = Command.define('LoadCatalog', {
  messages: [SucceededLoadCatalog],
  execute: Effect.sleep(CATALOG_LATENCY).pipe(
    Effect.as(SucceededLoadCatalog()),
  ),
})

export const LoadPainting = Command.define('LoadPainting', {
  args: { paintingId: S.Number },
  messages: [SucceededLoadPainting],
  execute: ({ paintingId }) =>
    Effect.sleep(PAINTING_LATENCY).pipe(
      Effect.as(SucceededLoadPainting({ paintingId })),
    ),
})

export const SaveDraft = Command.define('SaveDraft', {
  args: { draft: S.String },
  messages: [SucceededSaveDraft],
  execute: ({ draft }) =>
    Effect.sleep(SAVE_LATENCY).pipe(Effect.as(SucceededSaveDraft({ draft }))),
})

// UPDATE

type UpdateReturn = Update.Return<Model, Message>
type Step = Update.Step<Model, Message>
const withUpdateReturn = M.withReturnType<UpdateReturn>()

export type AppTransition = Transition.Transition<AppRoute>

const nextSequenceNumber = (
  transitionLog: ReadonlyArray<LoggedTransition>,
): number =>
  Option.match(Array.head(transitionLog), {
    onNone: () => 1,
    onSome: newestEntry => newestEntry.sequenceNumber + 1,
  })

const logTransition =
  (transition: AppTransition): Step =>
  model => [
    evo(model, {
      transitionLog: transitionLog =>
        pipe(
          transitionLog,
          Array.prepend({
            sequenceNumber: nextSequenceNumber(transitionLog),
            maybePreviousRoute: transition.maybePreviousRoute,
            nextRoute: transition.nextRoute,
          }),
          Array.take(MAX_LOGGED_TRANSITIONS),
        ),
    }),
    [],
  ]

const loadCatalogOnGalleryEntry =
  (transition: AppTransition): Step =>
  model =>
    Transition.isEntering(transition, 'Gallery') &&
    model.catalogStatus !== 'Loading'
      ? [evo(model, { catalogStatus: () => 'Loading' }), [LoadCatalog()]]
      : [model, []]

const loadPaintingOnEntry =
  (transition: AppTransition): Step =>
  model =>
    Option.match(Transition.entered(transition, 'Painting'), {
      onNone: () => [model, []],
      onSome: ({ paintingId }) => [
        evo(model, { paintingStatus: () => PaintingLoading({ paintingId }) }),
        [LoadPainting({ paintingId })],
      ],
    })

const reloadPaintingOnIdChange =
  (transition: AppTransition): Step =>
  model =>
    Option.match(Transition.stayed(transition, 'Painting'), {
      onNone: () => [model, []],
      onSome: ({ previousRoute, nextRoute }) =>
        previousRoute.paintingId === nextRoute.paintingId
          ? [model, []]
          : [
              evo(model, {
                paintingStatus: () =>
                  PaintingLoading({ paintingId: nextRoute.paintingId }),
              }),
              [LoadPainting({ paintingId: nextRoute.paintingId })],
            ],
    })

const saveDraftOnStudioExit =
  (transition: AppTransition): Step =>
  model =>
    Option.match(Transition.exited(transition, 'Studio'), {
      onNone: () => [model, []],
      onSome: () =>
        model.studioDraft === ''
          ? [model, []]
          : [model, [SaveDraft({ draft: model.studioDraft })]],
    })

const handleTransition = (
  model: Model,
  transition: AppTransition,
): UpdateReturn =>
  Update.combine(model, [
    logTransition(transition),
    loadCatalogOnGalleryEntry(transition),
    loadPaintingOnEntry(transition),
    reloadPaintingOnIdChange(transition),
    saveDraftOnStudioExit(transition),
  ])

export const update = (model: Model, message: Message): UpdateReturn =>
  M.value(message).pipe(
    withUpdateReturn,
    M.tagsExhaustive({
      CompletedNavigateInternal: () => [model, []],
      CompletedLoadExternal: () => [model, []],

      ClickedLink: ({ request }) =>
        M.value(request).pipe(
          withUpdateReturn,
          M.tagsExhaustive({
            Internal: ({ url }) => [
              model,
              [NavigateInternal({ url: urlToString(url) })],
            ],
            External: ({ href }) => [model, [LoadExternal({ href })]],
          }),
        ),

      ChangedUrl: ({ url }) => {
        const nextRoute = urlToAppRoute(url)
        const transition = Transition.make(model.route, nextRoute)
        return handleTransition(
          evo(model, { route: () => nextRoute }),
          transition,
        )
      },

      SucceededLoadCatalog: () => [
        evo(model, { catalogStatus: () => 'Ready' }),
        [],
      ],

      SucceededLoadPainting: ({ paintingId }) =>
        model.paintingStatus._tag === 'PaintingLoading' &&
        model.paintingStatus.paintingId === paintingId
          ? [
              evo(model, {
                paintingStatus: () => PaintingReady({ paintingId }),
              }),
              [],
            ]
          : [model, []],

      UpdatedStudioDraft: ({ value }) => [
        evo(model, { studioDraft: () => value }),
        [],
      ],

      SucceededSaveDraft: ({ draft }) => [
        evo(model, { maybeSavedDraft: () => Option.some(draft) }),
        [],
      ],
    }),
  )

// INIT

export const init: Runtime.RoutingApplicationInit<Model, Message> = (
  url: Url,
) => {
  const route = urlToAppRoute(url)
  const initialModel = Model.make({
    route,
    transitionLog: [],
    catalogStatus: 'Idle',
    paintingStatus: PaintingIdle(),
    studioDraft: '',
    maybeSavedDraft: Option.none(),
  })
  return handleTransition(initialModel, Transition.coldLoad(route))
}

// VIEW

const routeLabel = (route: AppRoute): string =>
  M.value(route).pipe(
    M.tagsExhaustive({
      Home: () => 'Home',
      Gallery: () => 'Gallery',
      Painting: ({ paintingId }) => `Painting ${paintingId}`,
      Studio: () => 'Studio',
      NotFound: () => 'Not found',
    }),
  )

const navigationView = (
  currentRoute: AppRoute,
  h: HtmlBuilder<Message>,
): Html => {
  const navLinkClassName = (isActive: boolean) =>
    `font-medium px-3 py-1 rounded transition hover:bg-indigo-500 ${isActive ? 'bg-indigo-700' : ''}`

  return h.nav(
    [h.Class('bg-indigo-600 text-white p-4')],
    [
      h.ul(
        [h.Class('max-w-6xl mx-auto flex gap-4 list-none')],
        [
          h.li(
            [],
            [
              h.a(
                [
                  h.Href(homeRouter()),
                  h.Class(navLinkClassName(currentRoute._tag === 'Home')),
                ],
                ['Home'],
              ),
            ],
          ),
          h.li(
            [],
            [
              h.a(
                [
                  h.Href(galleryRouter()),
                  h.Class(
                    navLinkClassName(
                      currentRoute._tag === 'Gallery' ||
                        currentRoute._tag === 'Painting',
                    ),
                  ),
                ],
                ['Gallery'],
              ),
            ],
          ),
          h.li(
            [],
            [
              h.a(
                [
                  h.Href(studioRouter()),
                  h.Class(navLinkClassName(currentRoute._tag === 'Studio')),
                ],
                ['Studio'],
              ),
            ],
          ),
        ],
      ),
    ],
  )
}

const homeView = (h: HtmlBuilder<Message>): Html =>
  h.div(
    [],
    [
      h.h1(
        [h.Class('text-4xl font-bold text-gray-800 mb-6')],
        ['Route Transitions'],
      ),
      h.p(
        [h.Class('text-lg text-gray-600 mb-4')],
        [
          'Every navigation in this app is described by the Transition helpers from foldkit/route, and the log on the right narrates what each one said. The cold load that brought you here is already in it.',
        ],
      ),
      h.p([h.Class('text-gray-600 mb-2')], ['Things to try:']),
      h.ul(
        [h.Class('list-disc pl-6 text-gray-600 space-y-2')],
        [
          h.li(
            [],
            [
              'Open the Gallery. Entering it fires a catalog load once; navigating back and forth fires it again only on each fresh entry.',
            ],
          ),
          h.li(
            [],
            [
              'Open a painting and flip to the next one. Staying on the Painting route is not an entry, so the log shows a stayed transition and only the changed id refetches.',
            ],
          ),
          h.li(
            [],
            [
              'Write a draft in the Studio and leave. Exiting the route is a fact, and it becomes a one-shot save Command.',
            ],
          ),
          h.li(
            [],
            [
              'Reload the page anywhere. A cold load has no previous route and still counts as an entry.',
            ],
          ),
        ],
      ),
    ],
  )

const loadingView = (label: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [
      h.Class(
        'border border-dashed border-gray-300 rounded-lg p-12 text-center text-gray-500',
      ),
    ],
    [label],
  )

const paintingGridView = (h: HtmlBuilder<Message>): Html =>
  h.ul(
    [h.Class('grid gap-4 sm:grid-cols-2 list-none')],
    Array.map(paintings, painting =>
      h.keyed('li')(
        String(painting.id),
        [],
        [
          h.a(
            [
              h.Href(paintingRouter({ paintingId: painting.id })),
              h.Class(
                'block bg-white rounded-lg shadow hover:shadow-md transition overflow-hidden',
              ),
            ],
            [
              h.div([h.Class(`h-28 bg-gradient-to-br ${painting.gradient}`)]),
              h.div(
                [h.Class('p-4')],
                [
                  h.h3(
                    [h.Class('font-semibold text-gray-800')],
                    [painting.title],
                  ),
                  h.p([h.Class('text-sm text-gray-500')], [painting.artist]),
                ],
              ),
            ],
          ),
        ],
      ),
    ),
  )

const galleryView = (
  catalogStatus: CatalogStatus,
  h: HtmlBuilder<Message>,
): Html => {
  const isCatalogReady = catalogStatus === 'Ready'

  return h.div(
    [],
    [
      h.h1([h.Class('text-4xl font-bold text-gray-800 mb-2')], ['Gallery']),
      h.p(
        [h.Class('text-gray-600 mb-6')],
        [
          'The catalog loads when a transition enters this route, whether by navigation or by cold load.',
        ],
      ),
      isCatalogReady
        ? paintingGridView(h)
        : loadingView('Hanging the paintings…', h),
    ],
  )
}

const neighborView = (
  label: string,
  maybeNeighbor: Option.Option<Painting>,
  h: HtmlBuilder<Message>,
): Html =>
  Option.match(maybeNeighbor, {
    onNone: () => h.span([h.Class('text-gray-300')], [label]),
    onSome: neighbor =>
      h.a(
        [
          h.Href(paintingRouter({ paintingId: neighbor.id })),
          h.Class('text-indigo-600 hover:underline font-medium'),
        ],
        [label],
      ),
  })

const paintingNeighborsView = (
  paintingIndex: number,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Class('flex items-center justify-between mt-6')],
    [
      neighborView('← Previous', Array.get(paintings, paintingIndex - 1), h),
      h.span(
        [h.Class('text-sm text-gray-500')],
        [`${paintingIndex + 1} of ${paintings.length}`],
      ),
      neighborView('Next →', Array.get(paintings, paintingIndex + 1), h),
    ],
  )

const missingPaintingView = (
  paintingId: number,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [],
    [
      h.h1(
        [h.Class('text-4xl font-bold text-red-600 mb-6')],
        ['Painting Not Found'],
      ),
      h.p(
        [h.Class('text-lg text-gray-600 mb-4')],
        [`No painting with id ${paintingId} hangs in this gallery.`],
      ),
      h.a(
        [h.Href(galleryRouter()), h.Class('text-indigo-600 hover:underline')],
        ['← Back to Gallery'],
      ),
    ],
  )

const foundPaintingView = (
  painting: Painting,
  paintingIndex: number,
  paintingStatus: PaintingStatus,
  h: HtmlBuilder<Message>,
): Html => {
  const isPaintingReady =
    paintingStatus._tag === 'PaintingReady' &&
    paintingStatus.paintingId === painting.id

  return h.div(
    [],
    [
      h.a(
        [
          h.Href(galleryRouter()),
          h.Class('text-indigo-600 hover:underline mb-4 inline-block'),
        ],
        ['← Back to Gallery'],
      ),
      isPaintingReady
        ? h.article(
            [h.Class('bg-white rounded-lg shadow overflow-hidden')],
            [
              h.div([h.Class(`h-56 bg-gradient-to-br ${painting.gradient}`)]),
              h.div(
                [h.Class('p-6')],
                [
                  h.h1(
                    [h.Class('text-3xl font-bold text-gray-800 mb-1')],
                    [painting.title],
                  ),
                  h.p([h.Class('text-gray-500')], [painting.artist]),
                ],
              ),
            ],
          )
        : loadingView('Unpacking the painting…', h),
      paintingNeighborsView(paintingIndex, h),
    ],
  )
}

const paintingView = (
  paintingId: number,
  paintingStatus: PaintingStatus,
  h: HtmlBuilder<Message>,
): Html =>
  Option.match(findPaintingWithIndex(paintingId), {
    onNone: () => missingPaintingView(paintingId, h),
    onSome: ({ painting, paintingIndex }) =>
      foundPaintingView(painting, paintingIndex, paintingStatus, h),
  })

const studioView = (
  studioDraft: string,
  maybeSavedDraft: Option.Option<string>,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [],
    [
      h.h1([h.Class('text-4xl font-bold text-gray-800 mb-2')], ['Studio']),
      h.p(
        [h.Class('text-gray-600 mb-6')],
        [
          'Write something, then leave. Exiting this route fires a one-shot SaveDraft Command with whatever is here.',
        ],
      ),
      h.textarea([
        h.Value(studioDraft),
        h.OnInput(value => UpdatedStudioDraft({ value })),
        h.Placeholder('A half-finished thought…'),
        h.Class(
          'w-full h-40 bg-white border border-gray-300 rounded-lg p-4 focus:outline-none focus:ring-2 focus:ring-indigo-500',
        ),
      ]),
      h.div(
        [h.Class('mt-6')],
        [
          Option.match(maybeSavedDraft, {
            onNone: () =>
              h.p([h.Class('text-sm text-gray-500')], ['Nothing saved yet.']),
            onSome: savedDraft =>
              h.div(
                [h.Class('bg-white border border-gray-200 rounded-lg p-4')],
                [
                  h.h2(
                    [
                      h.Class(
                        'text-sm font-medium text-gray-500 uppercase tracking-wide mb-1',
                      ),
                    ],
                    ['Last saved draft'],
                  ),
                  h.p([h.Class('text-gray-800')], [savedDraft]),
                ],
              ),
          }),
        ],
      ),
    ],
  )

const notFoundView = (path: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [],
    [
      h.h1(
        [h.Class('text-4xl font-bold text-red-600 mb-6')],
        ['404 - Page Not Found'],
      ),
      h.p(
        [h.Class('text-lg text-gray-600 mb-4')],
        [`The path "${path}" was not found.`],
      ),
      h.a(
        [h.Href(homeRouter()), h.Class('text-indigo-600 hover:underline')],
        ['← Go Home'],
      ),
    ],
  )

const badgeView = (
  className: string,
  label: string,
  h: HtmlBuilder<Message>,
): Html =>
  h.span(
    [
      h.Class(
        `text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap ${className}`,
      ),
    ],
    [label],
  )

const logEntryBadges = (
  transition: AppTransition,
  h: HtmlBuilder<Message>,
): ReadonlyArray<Html> => {
  const coldLoadBadges = Option.match(transition.maybePreviousRoute, {
    onNone: () => [badgeView('bg-violet-100 text-violet-700', 'Cold load', h)],
    onSome: () => [],
  })

  const maybeEnteredBadge = Option.map(
    Transition.enteredAny(transition),
    route =>
      badgeView('bg-emerald-100 text-emerald-700', `Entered ${route._tag}`, h),
  )

  const maybeExitedBadge = Option.map(Transition.exitedAny(transition), route =>
    badgeView('bg-amber-100 text-amber-700', `Exited ${route._tag}`, h),
  )

  const maybeStayedBadge = Option.map(
    Transition.stayed(transition, 'Painting'),
    ({ previousRoute, nextRoute }) =>
      badgeView(
        'bg-sky-100 text-sky-700',
        `Stayed on Painting: ${previousRoute.paintingId}${nextRoute.paintingId}`,
        h,
      ),
  )

  const helperBadges = Array.getSomes([
    maybeEnteredBadge,
    maybeExitedBadge,
    maybeStayedBadge,
  ])

  return Array.match([...coldLoadBadges, ...helperBadges], {
    onEmpty: () => [
      badgeView('bg-gray-100 text-gray-600', 'Stayed within route', h),
    ],
    onNonEmpty: badges => badges,
  })
}

const logEntryView = (
  entry: LoggedTransition,
  h: HtmlBuilder<Message>,
): Html => {
  const sourceLabel = Option.match(entry.maybePreviousRoute, {
    onNone: () => 'Cold load',
    onSome: routeLabel,
  })

  return h.keyed('li')(
    String(entry.sequenceNumber),
    [h.Class('border border-gray-200 rounded-md p-3')],
    [
      h.p(
        [h.Class('text-xs text-gray-500 mb-2')],
        [
          `#${entry.sequenceNumber} ${sourceLabel}${routeLabel(entry.nextRoute)}`,
        ],
      ),
      h.div([h.Class('flex flex-wrap gap-1.5')], logEntryBadges(entry, h)),
    ],
  )
}

const transitionLogView = (
  transitionLog: ReadonlyArray<LoggedTransition>,
  h: HtmlBuilder<Message>,
): Html =>
  h.aside(
    [h.Class('bg-white rounded-lg shadow p-4 h-fit lg:sticky lg:top-8')],
    [
      h.h2(
        [h.Class('text-lg font-bold text-gray-800 mb-1')],
        ['Transition Log'],
      ),
      h.p(
        [h.Class('text-sm text-gray-500 mb-4')],
        ['The most recent navigations, described by the Transition helpers.'],
      ),
      h.ul(
        [h.Class('space-y-3 list-none')],
        Array.map(transitionLog, entry => logEntryView(entry, h)),
      ),
    ],
  )

const routeTitle = (route: AppRoute): string =>
  M.value(route).pipe(
    M.tag('Home', () => 'Route Transitions'),
    M.orElse(currentRoute => `${routeLabel(currentRoute)} | Route Transitions`),
  )

export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  const routeContent = M.value(model.route).pipe(
    M.tagsExhaustive({
      Home: () => homeView(h),
      Gallery: () => galleryView(model.catalogStatus, h),
      Painting: ({ paintingId }) =>
        paintingView(paintingId, model.paintingStatus, h),
      Studio: () => studioView(model.studioDraft, model.maybeSavedDraft, h),
      NotFound: ({ path }) => notFoundView(path, h),
    }),
  )

  return {
    title: routeTitle(model.route),
    body: h.div(
      [h.Class('min-h-screen bg-gray-100')],
      [
        h.header([], [navigationView(model.route, h)]),
        h.main(
          [
            h.Class(
              'max-w-6xl mx-auto px-4 py-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px] items-start',
            ),
          ],
          [routeContent, transitionLogView(model.transitionLog, h)],
        ),
      ],
    ),
  }
}