All Examples
API Cache
Query caching without a query client. Demonstrates stale-while-revalidate, request deduplication, invalidation, and interval refetching.
Caching
Subscriptions
UI Components
/
import {
Array,
Clock,
Duration,
Effect,
HashMap,
Match as M,
Option,
Schema as S,
Stream,
pipe,
} from 'effect'
import { Command, Runtime, Subscription, Ui } from 'foldkit'
import { Document, Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { ts } from 'foldkit/schema'
import { evo } from 'foldkit/struct'
import {
Post,
PostDetail,
Stats,
fetchPostDetailFromServer,
fetchPostsFromServer,
fetchStatsFromServer,
} from './data'
const STATS_REFETCH_INTERVAL = Duration.seconds(5)
export const TABS_ID = 'api-cache-tabs'
// MODEL
const makeRemoteData = <DA, DI>(dataSchema: S.Codec<DA, DI>) => {
const NotAsked = ts('NotAsked')
const Loading = ts('Loading')
const Refreshing = ts('Refreshing', { data: dataSchema, fetchedAt: S.Number })
const Failure = ts('Failure', { error: S.String })
const Ok = ts('Ok', { data: dataSchema, fetchedAt: S.Number })
const Union = S.Union([NotAsked, Loading, Refreshing, Failure, Ok])
const refetch = (
current: typeof Union.Type,
): Option.Option<typeof Union.Type> =>
M.value(current).pipe(
M.withReturnType<Option.Option<typeof Union.Type>>(),
M.tagsExhaustive({
NotAsked: () => Option.some(Loading()),
Loading: () => Option.none(),
Refreshing: () => Option.none(),
Failure: () => Option.some(Loading()),
Ok: ({ data, fetchedAt }) =>
Option.some(Refreshing({ data, fetchedAt })),
}),
)
return {
NotAsked,
Loading,
Refreshing,
Failure,
Ok,
Union,
refetch,
}
}
export const PostsData = makeRemoteData(S.Array(Post))
export const PostDetailData = makeRemoteData(PostDetail)
export const StatsData = makeRemoteData(Stats)
type PostDetailData = typeof PostDetailData.Union.Type
const Tab = S.Literals(['Posts', 'Stats'])
type Tab = typeof Tab.Type
const tabValues: ReadonlyArray<Tab> = Tab.literals
export const AppTabs = Ui.Tabs.create<Tab>()
export const Model = S.Struct({
tabs: Ui.Tabs.Model,
activeTab: Tab,
posts: PostsData.Union,
postDetailById: S.HashMap(S.String, PostDetailData.Union),
maybeSelectedPostId: S.Option(S.String),
stats: StatsData.Union,
})
export type Model = typeof Model.Type
// MESSAGE
export const GotTabsMessage = m('GotTabsMessage', { message: Ui.Tabs.Message })
export const ClickedPost = m('ClickedPost', { postId: S.String })
export const ClickedBackToPosts = m('ClickedBackToPosts')
export const ClickedInvalidatePosts = m('ClickedInvalidatePosts')
export const ClickedRetryPosts = m('ClickedRetryPosts')
export const ClickedRetryPostDetail = m('ClickedRetryPostDetail', {
postId: S.String,
})
export const ClickedRefreshStats = m('ClickedRefreshStats')
export const ClickedRetryStats = m('ClickedRetryStats')
export const TickedRevalidateStats = m('TickedRevalidateStats')
export const SucceededFetchPosts = m('SucceededFetchPosts', {
posts: S.Array(Post),
fetchedAt: S.Number,
})
export const FailedFetchPosts = m('FailedFetchPosts', { error: S.String })
export const SucceededFetchPostDetail = m('SucceededFetchPostDetail', {
postId: S.String,
detail: PostDetail,
fetchedAt: S.Number,
})
export const FailedFetchPostDetail = m('FailedFetchPostDetail', {
postId: S.String,
error: S.String,
})
export const SucceededFetchStats = m('SucceededFetchStats', {
stats: Stats,
fetchedAt: S.Number,
})
export const FailedFetchStats = m('FailedFetchStats', { error: S.String })
export const Message = S.Union([
GotTabsMessage,
ClickedPost,
ClickedBackToPosts,
ClickedInvalidatePosts,
ClickedRetryPosts,
ClickedRetryPostDetail,
ClickedRefreshStats,
ClickedRetryStats,
TickedRevalidateStats,
SucceededFetchPosts,
FailedFetchPosts,
SucceededFetchPostDetail,
FailedFetchPostDetail,
SucceededFetchStats,
FailedFetchStats,
])
export type Message = typeof Message.Type
// UPDATE
type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
const refetchPosts = (model: Model): UpdateReturn =>
Option.match(PostsData.refetch(model.posts), {
onNone: () => [model, []],
onSome: nextPosts => [
evo(model, { posts: () => nextPosts }),
[FetchPosts()],
],
})
const refetchStats = (model: Model): UpdateReturn =>
Option.match(StatsData.refetch(model.stats), {
onNone: () => [model, []],
onSome: nextStats => [
evo(model, { stats: () => nextStats }),
[FetchStats()],
],
})
const setPostDetail = (postId: string, postDetail: PostDetailData) =>
HashMap.set(postId, postDetail)
const activateTab = (model: Model, tab: Tab): UpdateReturn => {
const modelWithActiveTab = evo(model, { activeTab: () => tab })
return M.value(tab).pipe(
M.withReturnType<UpdateReturn>(),
M.when('Posts', () =>
M.value(modelWithActiveTab.posts).pipe(
M.withReturnType<UpdateReturn>(),
M.tag('NotAsked', () => [
evo(modelWithActiveTab, { posts: () => PostsData.Loading() }),
[FetchPosts()],
]),
M.orElse(() => [modelWithActiveTab, []]),
),
),
M.when('Stats', () =>
M.value(modelWithActiveTab.stats).pipe(
M.withReturnType<UpdateReturn>(),
M.tag('NotAsked', () => [
evo(modelWithActiveTab, { stats: () => StatsData.Loading() }),
[FetchStats()],
]),
M.orElse(() => [modelWithActiveTab, []]),
),
),
M.exhaustive,
)
}
export const update = (model: Model, message: Message): UpdateReturn =>
M.value(message).pipe(
M.withReturnType<UpdateReturn>(),
M.tagsExhaustive({
GotTabsMessage: ({ message }) => {
const [nextTabs, tabsCommands, maybeSelected] = AppTabs.update(
model.tabs,
message,
)
const tabsModel = evo(model, { tabs: () => nextTabs })
const mappedCommands = Command.mapMessages(tabsCommands, message =>
GotTabsMessage({ message }),
)
return Option.match(maybeSelected, {
onNone: () => [tabsModel, mappedCommands],
onSome: ({ value }) => {
const [activatedModel, activationCommands] = activateTab(
tabsModel,
value,
)
return [
activatedModel,
Array.appendAll(mappedCommands, activationCommands),
]
},
})
},
ClickedPost: ({ postId }) => {
const selectedModel = evo(model, {
maybeSelectedPostId: () => Option.some(postId),
})
return Option.match(HashMap.get(model.postDetailById, postId), {
onNone: () => [
evo(selectedModel, {
postDetailById: setPostDetail(postId, PostDetailData.Loading()),
}),
[FetchPostDetail({ postId })],
],
onSome: () => [selectedModel, []],
})
},
ClickedBackToPosts: () => [
evo(model, { maybeSelectedPostId: () => Option.none() }),
[],
],
ClickedInvalidatePosts: () => refetchPosts(model),
ClickedRetryPosts: () => refetchPosts(model),
ClickedRetryPostDetail: ({ postId }) => [
evo(model, {
postDetailById: setPostDetail(postId, PostDetailData.Loading()),
}),
[FetchPostDetail({ postId })],
],
ClickedRefreshStats: () => refetchStats(model),
ClickedRetryStats: () => refetchStats(model),
TickedRevalidateStats: () => refetchStats(model),
SucceededFetchPosts: ({ posts, fetchedAt }) => [
evo(model, { posts: () => PostsData.Ok({ data: posts, fetchedAt }) }),
[],
],
FailedFetchPosts: ({ error }) => [
evo(model, { posts: () => PostsData.Failure({ error }) }),
[],
],
SucceededFetchPostDetail: ({ postId, detail, fetchedAt }) => [
evo(model, {
postDetailById: setPostDetail(
postId,
PostDetailData.Ok({ data: detail, fetchedAt }),
),
}),
[],
],
FailedFetchPostDetail: ({ postId, error }) => [
evo(model, {
postDetailById: setPostDetail(
postId,
PostDetailData.Failure({ error }),
),
}),
[],
],
SucceededFetchStats: ({ stats, fetchedAt }) => [
evo(model, { stats: () => StatsData.Ok({ data: stats, fetchedAt }) }),
[],
],
FailedFetchStats: ({ error }) => [
evo(model, { stats: () => StatsData.Failure({ error }) }),
[],
],
}),
)
// INIT
export const init: Runtime.ApplicationInit<Model, Message> = () => [
{
tabs: Ui.Tabs.init({ id: TABS_ID }),
activeTab: 'Posts',
posts: PostsData.Loading(),
postDetailById: HashMap.empty(),
maybeSelectedPostId: Option.none(),
stats: StatsData.NotAsked(),
},
[FetchPosts()],
]
// COMMAND
export const FetchPosts = Command.define(
'FetchPosts',
SucceededFetchPosts,
FailedFetchPosts,
)(
Effect.gen(function* () {
const posts = yield* fetchPostsFromServer
const fetchedAt = yield* Clock.currentTimeMillis
return SucceededFetchPosts({ posts, fetchedAt })
}),
)
export const FetchPostDetail = Command.define(
'FetchPostDetail',
{ postId: S.String },
SucceededFetchPostDetail,
FailedFetchPostDetail,
)(({ postId }) =>
Effect.gen(function* () {
const detail = yield* fetchPostDetailFromServer(postId)
const fetchedAt = yield* Clock.currentTimeMillis
return SucceededFetchPostDetail({ postId, detail, fetchedAt })
}).pipe(
Effect.catch(error =>
Effect.succeed(FailedFetchPostDetail({ postId, error })),
),
),
)
export const FetchStats = Command.define(
'FetchStats',
SucceededFetchStats,
FailedFetchStats,
)(
Effect.gen(function* () {
const stats = yield* fetchStatsFromServer
const fetchedAt = yield* Clock.currentTimeMillis
return SucceededFetchStats({ stats, fetchedAt })
}),
)
// SUBSCRIPTION
export const subscriptions = Subscription.make<Model, Message>()(entry => ({
revalidateStats: entry(
{ isObservingStats: S.Boolean },
{
modelToDependencies: model => ({
isObservingStats:
model.activeTab === 'Stats' &&
(model.stats._tag === 'Ok' || model.stats._tag === 'Refreshing'),
}),
dependenciesToStream: ({ isObservingStats }) =>
Stream.when(
// NOTE: Stream.tick emits once immediately. Drop that first
// emission so freshly loaded stats are not refetched instantly.
Stream.tick(STATS_REFETCH_INTERVAL).pipe(
Stream.drop(1),
Stream.map(TickedRevalidateStats),
),
Effect.sync(() => isObservingStats),
),
},
),
}))
// VIEW
const formatFetchedAt = (fetchedAt: number): string =>
new Date(fetchedAt).toLocaleTimeString()
const remoteDataKey = (remoteDataTag: string): string =>
M.value(remoteDataTag).pipe(
M.whenOr('Ok', 'Refreshing', () => 'Loaded'),
M.orElse(() => remoteDataTag),
)
const tabButtonClassName =
'px-4 py-2 rounded-lg bg-white text-slate-600 font-semibold hover:bg-slate-50 transition cursor-pointer data-[selected]:bg-indigo-600 data-[selected]:text-white data-[selected]:hover:bg-indigo-600'
const toolbarButtonClassName =
'px-3 py-1.5 bg-white text-slate-700 text-sm font-semibold rounded-md shadow hover:bg-slate-50 transition cursor-pointer data-[disabled]:opacity-50 data-[disabled]:cursor-default data-[disabled]:hover:bg-white'
export const view = (model: Model): Document => {
const h = html<Message>()
return {
title: 'API Cache',
body: h.div(
[h.Class('min-h-screen bg-slate-100 flex justify-center p-6')],
[
h.div(
[h.Class('w-full max-w-2xl flex flex-col gap-6')],
[
headerView(),
h.submodel({
slotId: TABS_ID,
model: model.tabs,
view: AppTabs.view,
viewInputs: {
tabs: tabValues,
ariaLabel: 'API cache sections',
toView: ({ tablist, tabs }) =>
h.div(
[h.Class('flex flex-col gap-6')],
[
h.div(
[...tablist, h.Class('flex gap-2')],
Array.map(tabs, tabInfo =>
h.keyed('button')(
tabInfo.value,
[...tabInfo.tab, h.Class(tabButtonClassName)],
[tabInfo.value],
),
),
),
...pipe(
tabs,
Array.filter(tabInfo => tabInfo.isActive),
Array.map(tabInfo =>
h.keyed('div')(
tabInfo.value,
[...tabInfo.panel, h.Class('flex flex-col gap-4')],
[
M.value(tabInfo.value).pipe(
M.when('Posts', () => postsTabView(model)),
M.when('Stats', () => statsTabView(model)),
M.exhaustive,
),
],
),
),
),
],
),
},
toParentMessage: message => GotTabsMessage({ message }),
}),
],
),
],
),
}
}
const headerView = (): Html => {
const h = html<Message>()
return h.header(
[h.Class('flex flex-col gap-1')],
[
h.h1([h.Class('text-3xl font-bold text-slate-900')], ['API Cache']),
h.p(
[h.Class('text-slate-600')],
[
'Query client patterns written as ordinary Model state, update logic, and one Subscription.',
],
),
],
)
}
const postsTabView = (model: Model): Html => {
const h = html<Message>()
return Option.match(model.maybeSelectedPostId, {
onNone: () =>
h.keyed('section')(
'PostsList',
[h.Class('flex flex-col gap-4')],
[postsListView(model)],
),
onSome: postId =>
h.keyed('section')(
postId,
[h.Class('flex flex-col gap-4')],
[postDetailView(model, postId)],
),
})
}
const postsListView = (model: Model): Html => {
const h = html<Message>()
const isFetchInFlight =
model.posts._tag === 'Loading' || model.posts._tag === 'Refreshing'
return h.div(
[h.Class('flex flex-col gap-4')],
[
h.div(
[h.Class('flex items-center justify-between')],
[
h.h2([h.Class('text-xl font-bold text-slate-800')], ['Posts']),
Ui.Button.view({
onClick: ClickedInvalidatePosts(),
isDisabled: isFetchInFlight,
toView: attributes =>
h.button(
[...attributes.button, h.Class(toolbarButtonClassName)],
[
M.value(model.posts).pipe(
M.tag('Refreshing', () => 'Refreshing...'),
M.orElse(() => 'Invalidate'),
),
],
),
}),
],
),
h.p(
[h.Class('text-sm text-slate-500')],
[
'Open a post, go back, and open it again. The second visit renders instantly from the Model. Invalidate marks the list stale and refetches it while the current list stays on screen.',
],
),
h.keyed('div')(
remoteDataKey(model.posts._tag),
[],
[
M.value(model.posts).pipe(
M.tagsExhaustive({
NotAsked: () => loadingPanel('Loading posts...'),
Loading: () => loadingPanel('Loading posts...'),
Failure: ({ error }) => errorPanel(error, ClickedRetryPosts()),
Refreshing: ({ data }) =>
postListItems(data, model.postDetailById),
Ok: ({ data }) => postListItems(data, model.postDetailById),
}),
),
],
),
],
)
}
const isPostDetailCached = (
postDetailById: HashMap.HashMap<string, PostDetailData>,
postId: string,
): boolean =>
Option.exists(
HashMap.get(postDetailById, postId),
postDetail => postDetail._tag === 'Ok',
)
const postListItems = (
posts: ReadonlyArray<Post>,
postDetailById: HashMap.HashMap<string, PostDetailData>,
): Html => {
const h = html<Message>()
return h.ul(
[h.Class('flex flex-col gap-2')],
Array.map(posts, post =>
h.keyed('li')(
post.id,
[],
[
Ui.Button.view({
onClick: ClickedPost({ postId: post.id }),
toView: attributes =>
h.button(
[
...attributes.button,
h.Class(
'w-full text-left bg-white rounded-lg shadow px-4 py-3 hover:bg-slate-50 transition cursor-pointer flex items-center justify-between gap-4',
),
],
[
h.div(
[],
[
h.div(
[h.Class('font-semibold text-slate-800')],
[post.title],
),
h.div(
[h.Class('text-sm text-slate-500')],
[post.excerpt],
),
],
),
isPostDetailCached(postDetailById, post.id)
? h.span(
[
h.Class(
'shrink-0 text-xs font-semibold text-emerald-700 bg-emerald-100 rounded-full px-2 py-1',
),
],
['Cached'],
)
: h.empty,
],
),
}),
],
),
),
)
}
const postDetailView = (model: Model, postId: string): Html => {
const h = html<Message>()
const postDetailData = Option.getOrElse(
HashMap.get(model.postDetailById, postId),
() => PostDetailData.NotAsked(),
)
return h.div(
[h.Class('flex flex-col gap-4')],
[
Ui.Button.view({
onClick: ClickedBackToPosts(),
toView: attributes =>
h.button(
[
...attributes.button,
h.Class(
'self-start text-sm font-semibold text-indigo-600 hover:underline cursor-pointer',
),
],
['Back to posts'],
),
}),
h.keyed('div')(
remoteDataKey(postDetailData._tag),
[],
[
M.value(postDetailData).pipe(
M.tagsExhaustive({
NotAsked: () => loadingPanel('Loading post...'),
Loading: () => loadingPanel('Loading post...'),
Failure: ({ error }) =>
errorPanel(error, ClickedRetryPostDetail({ postId })),
Refreshing: ({ data, fetchedAt }) =>
postDetailCard(data, fetchedAt),
Ok: ({ data, fetchedAt }) => postDetailCard(data, fetchedAt),
}),
),
],
),
],
)
}
const postDetailCard = (detail: PostDetail, fetchedAt: number): Html => {
const h = html<Message>()
return h.article(
[h.Class('bg-white rounded-xl shadow p-6 flex flex-col gap-3')],
[
h.h2([h.Class('text-2xl font-bold text-slate-900')], [detail.title]),
h.p([h.Class('text-sm text-slate-500')], [`By ${detail.author}`]),
h.p([h.Class('text-slate-700 leading-relaxed')], [detail.body]),
h.p(
[h.Class('text-xs text-slate-400')],
[
`Fetched at ${formatFetchedAt(fetchedAt)}. Future visits render instantly from the Model.`,
],
),
],
)
}
const statsTabView = (model: Model): Html => {
const h = html<Message>()
const isFetchInFlight =
model.stats._tag === 'Loading' || model.stats._tag === 'Refreshing'
return h.div(
[h.Class('flex flex-col gap-4')],
[
h.div(
[h.Class('flex items-center justify-between')],
[
h.h2([h.Class('text-xl font-bold text-slate-800')], ['Stats']),
Ui.Button.view({
onClick: ClickedRefreshStats(),
isDisabled: isFetchInFlight,
toView: attributes =>
h.button(
[...attributes.button, h.Class(toolbarButtonClassName)],
[isFetchInFlight ? 'Refreshing...' : 'Refresh'],
),
}),
],
),
h.p(
[h.Class('text-sm text-slate-500')],
[
'Stats refetch every 5 seconds while this tab is open. The old numbers stay on screen while the new ones load.',
],
),
h.keyed('div')(
remoteDataKey(model.stats._tag),
[],
[
M.value(model.stats).pipe(
M.tagsExhaustive({
NotAsked: () => loadingPanel('Loading stats...'),
Loading: () => loadingPanel('Loading stats...'),
Failure: ({ error }) => errorPanel(error, ClickedRetryStats()),
Refreshing: ({ data, fetchedAt }) =>
statsCards(data, fetchedAt, true),
Ok: ({ data, fetchedAt }) => statsCards(data, fetchedAt, false),
}),
),
],
),
],
)
}
const statsCards = (
stats: Stats,
fetchedAt: number,
isRefreshing: boolean,
): Html => {
const h = html<Message>()
return h.div(
[h.Class('flex flex-col gap-3')],
[
h.div(
[h.Class('grid grid-cols-3 gap-4')],
[
statCard('Active users', `${stats.activeUsers}`),
statCard('Requests per second', `${stats.requestsPerSecond}`),
statCard('Cache hit rate', `${stats.cacheHitRatePercent}%`),
],
),
h.div(
[h.Class('flex items-center gap-3 text-sm text-slate-500')],
[
h.span([], [`Updated at ${formatFetchedAt(fetchedAt)}`]),
isRefreshing
? h.span([h.Class('text-indigo-600 font-semibold')], ['Refreshing'])
: h.empty,
],
),
],
)
}
const statCard = (label: string, value: string): Html => {
const h = html<Message>()
return h.div(
[h.Class('bg-white rounded-xl shadow p-4 flex flex-col gap-1')],
[
h.div([h.Class('text-sm text-slate-500')], [label]),
h.div([h.Class('text-2xl font-bold text-slate-900')], [value]),
],
)
}
const loadingPanel = (text: string): Html => {
const h = html<Message>()
return h.div(
[h.Class('bg-white rounded-lg shadow p-6 text-center text-slate-500')],
[text],
)
}
const errorPanel = (error: string, retryMessage: Message): Html => {
const h = html<Message>()
return h.div(
[
h.Class(
'bg-red-50 border border-red-200 text-red-700 rounded-lg p-4 flex items-center justify-between gap-4',
),
],
[
h.p([], [error]),
Ui.Button.view({
onClick: retryMessage,
toView: attributes =>
h.button(
[
...attributes.button,
h.Class(
'shrink-0 px-3 py-1.5 bg-red-600 text-white text-sm font-semibold rounded-md hover:bg-red-700 transition cursor-pointer',
),
],
['Retry'],
),
}),
],
)
}