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 { AsyncData, Command, Runtime, Subscription } from 'foldkit'
import { Document, Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { Button, Tabs } from '@foldkit/ui'
import {
Post,
PostDetail,
Stats,
fetchPostDetail,
fetchPosts,
fetchStats,
} from './data'
const STATS_REFETCH_INTERVAL = Duration.seconds(5)
export const TABS_ID = 'api-cache-tabs'
// MODEL
const FetchedPosts = S.Struct({ posts: S.Array(Post), fetchedAt: S.Number })
const FetchedPostDetail = S.Struct({
detail: PostDetail,
fetchedAt: S.Number,
})
const FetchedStats = S.Struct({ stats: Stats, fetchedAt: S.Number })
export const PostsData = AsyncData.Schema(FetchedPosts, S.String)
export const PostDetailData = AsyncData.Schema(FetchedPostDetail, S.String)
export const StatsData = AsyncData.Schema(FetchedStats, S.String)
type PostsData = typeof PostsData.schema.Type
type PostDetailData = typeof PostDetailData.schema.Type
type StatsData = typeof StatsData.schema.Type
const Tab = S.Literals(['Posts', 'Stats'])
type Tab = typeof Tab.Type
const tabValues: ReadonlyArray<Tab> = Tab.literals
export const AppTabs = Tabs.create<Tab>()
export const Model = S.Struct({
tabs: Tabs.Model,
activeTab: Tab,
posts: PostsData.schema,
postDetailById: S.HashMap(S.String, PostDetailData.schema),
maybeSelectedPostId: S.Option(S.String),
stats: StatsData.schema,
})
export type Model = typeof Model.Type
// MESSAGE
export const GotTabsMessage = m('GotTabsMessage', { message: 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 SettledFetchPosts = m('SettledFetchPosts', {
result: S.Result(FetchedPosts, S.String),
})
export const SettledFetchPostDetail = m('SettledFetchPostDetail', {
postId: S.String,
result: S.Result(FetchedPostDetail, S.String),
})
export const SettledFetchStats = m('SettledFetchStats', {
result: S.Result(FetchedStats, S.String),
})
export const Message = S.Union([
GotTabsMessage,
ClickedPost,
ClickedBackToPosts,
ClickedInvalidatePosts,
ClickedRetryPosts,
ClickedRetryPostDetail,
ClickedRefreshStats,
ClickedRetryStats,
TickedRevalidateStats,
SettledFetchPosts,
SettledFetchPostDetail,
SettledFetchStats,
])
export type Message = typeof Message.Type
// UPDATE
type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
const applyPostsTransition = (
model: Model,
maybeNextPosts: Option.Option<PostsData>,
): UpdateReturn =>
Option.match(maybeNextPosts, {
onNone: () => [model, []],
onSome: nextPosts => [
evo(model, { posts: () => nextPosts }),
[FetchPosts()],
],
})
const applyStatsTransition = (
model: Model,
maybeNextStats: Option.Option<StatsData>,
): UpdateReturn =>
Option.match(maybeNextStats, {
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', () =>
applyPostsTransition(
modelWithActiveTab,
AsyncData.loadIfMissing(modelWithActiveTab.posts),
),
),
M.when('Stats', () =>
applyStatsTransition(
modelWithActiveTab,
AsyncData.loadIfMissing(modelWithActiveTab.stats),
),
),
M.exhaustive,
)
}
export const update = (model: Model, message: Message): UpdateReturn =>
M.value(message).pipe(
M.withReturnType<UpdateReturn>(),
M.tagsExhaustive({
GotTabsMessage: ({ message }) => {
const [nextTabs, tabsCommands, maybeOutMessage] = AppTabs.update(
model.tabs,
message,
)
const tabsModel = evo(model, { tabs: () => nextTabs })
const mappedCommands = Command.mapMessages(tabsCommands, message =>
GotTabsMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [tabsModel, mappedCommands],
onSome: M.type<Tabs.OutMessage<Tab>>().pipe(
M.withReturnType<UpdateReturn>(),
M.tagsExhaustive({
Selected: ({ 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: () =>
applyPostsTransition(model, AsyncData.revalidateOrLoad(model.posts)),
ClickedRetryPosts: () =>
applyPostsTransition(model, AsyncData.revalidateOrLoad(model.posts)),
ClickedRetryPostDetail: ({ postId }) => [
evo(model, {
postDetailById: setPostDetail(postId, PostDetailData.Loading()),
}),
[FetchPostDetail({ postId })],
],
ClickedRefreshStats: () =>
applyStatsTransition(model, AsyncData.revalidateOrLoad(model.stats)),
ClickedRetryStats: () =>
applyStatsTransition(model, AsyncData.revalidateOrLoad(model.stats)),
TickedRevalidateStats: () =>
applyStatsTransition(model, AsyncData.revalidate(model.stats)),
SettledFetchPosts: ({ result }) => [
evo(model, { posts: AsyncData.settle(result) }),
[],
],
SettledFetchPostDetail: ({ postId, result }) => [
evo(model, {
postDetailById: HashMap.modify(postId, AsyncData.settle(result)),
}),
[],
],
SettledFetchStats: ({ result }) => [
evo(model, { stats: AsyncData.settle(result) }),
[],
],
}),
)
// INIT
export const init: Runtime.ApplicationInit<Model, Message> = () => [
{
tabs: Tabs.init({ id: TABS_ID }),
activeTab: 'Posts',
posts: PostsData.Loading(),
postDetailById: HashMap.empty(),
maybeSelectedPostId: Option.none(),
stats: StatsData.Idle(),
},
[FetchPosts()],
]
// COMMAND
export const FetchPosts = Command.define(
'FetchPosts',
SettledFetchPosts,
)(
pipe(
Effect.gen(function* () {
const posts = yield* fetchPosts
const fetchedAt = yield* Clock.currentTimeMillis
return FetchedPosts.make({ posts, fetchedAt })
}),
Effect.result,
Effect.map(result => SettledFetchPosts({ result })),
),
)
export const FetchPostDetail = Command.define(
'FetchPostDetail',
{ postId: S.String },
SettledFetchPostDetail,
)(({ postId }) =>
pipe(
Effect.gen(function* () {
const detail = yield* fetchPostDetail(postId)
const fetchedAt = yield* Clock.currentTimeMillis
return FetchedPostDetail.make({ detail, fetchedAt })
}),
Effect.result,
Effect.map(result => SettledFetchPostDetail({ postId, result })),
),
)
export const FetchStats = Command.define(
'FetchStats',
SettledFetchStats,
)(
pipe(
Effect.gen(function* () {
const stats = yield* fetchStats
const fetchedAt = yield* Clock.currentTimeMillis
return FetchedStats.make({ stats, fetchedAt })
}),
Effect.result,
Effect.map(result => SettledFetchStats({ result })),
),
)
// SUBSCRIPTION
export const subscriptions = Subscription.make<Model, Message>()(entry => ({
revalidateStats: entry(
{ isObservingStats: S.Boolean },
{
modelToDependencies: model => ({
isObservingStats:
model.activeTab === 'Stats' && AsyncData.hasData(model.stats),
}),
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 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,
selectedValue: model.activeTab,
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.section([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 isPending = AsyncData.isPending(model.posts)
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']),
Button.view({
onClick: ClickedInvalidatePosts(),
isDisabled: isPending,
toView: attributes =>
h.button(
[...attributes.button, h.Class(toolbarButtonClassName)],
[
AsyncData.isRefreshing(model.posts)
? 'Refreshing...'
: '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.',
],
),
AsyncData.matchDataSplitEmpty(model.posts, {
onIdle: () => loadingPanel('Loading posts...'),
onLoading: () => loadingPanel('Loading posts...'),
onFailure: error => errorPanel(error, ClickedRetryPosts()),
onData: ({ posts }) =>
h.div(
[h.Class('flex flex-col gap-4')],
[
...Option.match(AsyncData.getError(model.posts), {
onNone: () => [],
onSome: error => [staleView(error, ClickedRetryPosts())],
}),
h.ul(
[h.Class('flex flex-col gap-2')],
postListItems(posts, model.postDetailById),
),
],
),
}),
],
)
}
const isPostDetailCached = (
postDetailById: HashMap.HashMap<string, PostDetailData>,
postId: string,
): boolean =>
Option.exists(HashMap.get(postDetailById, postId), AsyncData.hasData)
const postListItems = (
posts: ReadonlyArray<Post>,
postDetailById: HashMap.HashMap<string, PostDetailData>,
): ReadonlyArray<Html> => {
const h = html<Message>()
return Array.map(posts, post =>
h.keyed('li')(
post.id,
[],
[
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.Idle(),
)
return h.div(
[h.Class('flex flex-col gap-4')],
[
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'],
),
}),
AsyncData.matchDataSplitEmpty(postDetailData, {
onIdle: () => loadingPanel('Loading post...'),
onLoading: () => loadingPanel('Loading post...'),
onFailure: error =>
errorPanel(error, ClickedRetryPostDetail({ postId })),
onData: ({ detail, fetchedAt }) =>
h.div(
[h.Class('flex flex-col gap-4')],
[
...Option.match(AsyncData.getError(postDetailData), {
onNone: () => [],
onSome: error => [
staleView(error, ClickedRetryPostDetail({ postId })),
],
}),
postDetailCard(detail, 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 isPending = AsyncData.isPending(model.stats)
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']),
Button.view({
onClick: ClickedRefreshStats(),
isDisabled: isPending,
toView: attributes =>
h.button(
[...attributes.button, h.Class(toolbarButtonClassName)],
[isPending ? '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.',
],
),
AsyncData.matchDataSplitEmpty(model.stats, {
onIdle: () => loadingPanel('Loading stats...'),
onLoading: () => loadingPanel('Loading stats...'),
onFailure: error => errorPanel(error, ClickedRetryStats()),
onData: ({ stats, fetchedAt }) =>
h.div(
[h.Class('flex flex-col gap-4')],
[
...Option.match(AsyncData.getError(model.stats), {
onNone: () => [],
onSome: error => [staleView(error, ClickedRetryStats())],
}),
statsCards(stats, fetchedAt, AsyncData.isRefreshing(model.stats)),
],
),
}),
],
)
}
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'],
),
]
: []),
],
),
],
)
}
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 staleView = (error: string, retryMessage: Message): Html =>
errorPanel(error, retryMessage)
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]),
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'],
),
}),
],
)
}