Skip to content

Data Fetching Patterns

Este exemplo demonstra padrões avançados de carregamento de dados usando Slash. Você aprenderá sobre loaders isomórficos, cache, invalidação, loading states, error handling, pagination, infinite scroll e otimização de performance.

  • ✅ Loaders isomórficos (SSR + Client)
  • ✅ Cache com TTL no cliente
  • ✅ Invalidação manual
  • ✅ Loading states e skeletons
  • ✅ Error handling e retry
  • ✅ Pagination e infinite scroll
  • ✅ Optimistic updates
  • ✅ Prefetching
  • ✅ Parallel e serial data loading
  • ✅ Dependent queries
src/
├── index.ts # Entry point
├── loaders/
│ ├── posts.ts # Loader de posts
│ └── users.ts # Loader de usuários
├── components/
│ ├── PostList.ts # Lista com paginação
│ ├── InfiniteScroll.ts # Lista com infinite scroll
│ ├── UserProfile.ts # Profile com dependent queries
│ └── OptimisticPost.ts # Updates otimistas
├── query.ts # Controlador reutilizável de carregamento
└── types.ts # Tipos
src/types.ts
export interface Post {
id: string
title: string
body: string
userId: string
createdAt: string
}
export interface User {
id: string
name: string
email: string
avatar: string
}
export interface Comment {
id: string
postId: string
userId: string
body: string
createdAt: string
}
export interface PaginatedResponse<T> {
data: T[]
page: number
pageSize: number
total: number
hasMore: boolean
}
export interface LoaderState<T> {
data: T | null
isLoading: boolean
error: Error | null
isFetching: boolean
}

createLoader(fn, { key, ttl }) recebe uma função que lê { params } e devolve uma função assíncrona. No cliente o resultado é cacheado por key + params pelo ttl; no servidor sempre executa.

src/loaders/posts.ts
import { createLoader, invalidateLoader } from '@_bashell/slash'
import type { Post, PaginatedResponse } from '../types'
// Loader de lista de posts com paginação
export const postsLoader = createLoader<PaginatedResponse<Post>>(
async ({ params }) => {
const page = Number(params.page ?? '1')
const pageSize = Number(params.pageSize ?? '10')
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${pageSize}`
)
if (!response.ok) throw new Error('Failed to fetch posts')
const data = await response.json()
const total = parseInt(response.headers.get('x-total-count') || '100')
return { data, page, pageSize, total, hasMore: page * pageSize < total }
},
{ key: 'posts', ttl: 5 * 60 * 1000 } // Cache por 5 minutos
)
// Loader de post individual
export const postLoader = createLoader<Post>(
async ({ params }) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts/${params.id}`
)
if (!response.ok) throw new Error('Post not found')
return response.json()
},
{ key: 'post', ttl: 10 * 60 * 1000 } // Cache por 10 minutos
)
// Chamada de conveniência no cliente
export const loadPosts = (page = 1, pageSize = 10) =>
postsLoader({ params: { page: String(page), pageSize: String(pageSize) }, isServer: false })
// Criar novo post (com invalidação)
export const createPost = async (post: Omit<Post, 'id'>): Promise<Post> => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post)
})
if (!response.ok) throw new Error('Failed to create post')
invalidateLoader('posts') // a lista em cache ficou velha
return response.json()
}
// Atualizar post (com invalidação)
export const updatePost = async (id: string, updates: Partial<Post>): Promise<Post> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
})
if (!response.ok) throw new Error('Failed to update post')
invalidateLoader('posts')
invalidateLoader('post')
return response.json()
}
// Remover post (com invalidação)
export const deletePost = async (id: string): Promise<void> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
method: 'DELETE'
})
if (!response.ok) throw new Error('Failed to delete post')
invalidateLoader('posts')
invalidateLoader('post')
}

Em vez de um “hook” criado dentro do componente, crie um controlador uma vez, fora dos componentes. Ele guarda o state e expõe load:

src/query.ts
import { createState, batch } from '@_bashell/slash'
import type { LoaderState } from './types'
export function createQuery<T>(
fetcher: () => Promise<T>,
options?: { retry?: number; retryDelay?: number }
) {
const state = createState<LoaderState<T>>({
data: null,
isLoading: true,
error: null,
isFetching: false
})
const retry = options?.retry ?? 0
const retryDelay = options?.retryDelay ?? 1000
let attempt = 0
const load = async (): Promise<void> => {
batch(() => {
state.set({
...state.get(),
isLoading: state.get().data === null,
isFetching: true,
error: null
})
})
try {
const data = await fetcher()
attempt = 0
state.set({ data, isLoading: false, error: null, isFetching: false })
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error')
if (attempt < retry) {
attempt++
setTimeout(load, retryDelay * attempt)
} else {
state.set({ ...state.get(), isLoading: false, error: err, isFetching: false })
}
}
}
return { state, load, refetch: load }
}

Uso: o componente lê query.state.get() e o carregamento é disparado fora dele:

import { html, render } from '@_bashell/slash'
import { createQuery } from './query'
import { postLoader } from './loaders/posts'
const post = createQuery(() => postLoader({ params: { id: '1' }, isServer: false }), { retry: 2 })
const PostView = () => {
const { data, isLoading, error } = post.state.get()
if (isLoading) return html`<p>Loading...</p>`
if (error) return html`<p class="error">${error.message} <button onClick=${post.refetch}>Retry</button></p>`
return html`<h1>${data!.title}</h1>`
}
render(html`<${PostView} />`, '#app')
post.load()
src/components/PostList.ts
import { html, createState, batch } from '@_bashell/slash'
import { loadPosts } from '../loaders/posts'
import type { Post, PaginatedResponse } from '../types'
const pageSize = 10
// States fora do componente
const listState = createState<{
page: number
result: PaginatedResponse<Post> | null
isLoading: boolean
error: string | null
}>({ page: 1, result: null, isLoading: true, error: null })
export const goToPage = async (page: number) => {
batch(() => listState.set({ ...listState.get(), page, isLoading: true, error: null }))
try {
const result = await loadPosts(page, pageSize) // cache por página
listState.set({ page, result, isLoading: false, error: null })
window.scrollTo({ top: 0, behavior: 'smooth' })
} catch (error) {
listState.set({ ...listState.get(), isLoading: false, error: (error as Error).message })
}
}
export const PostList = () => {
const { page, result, isLoading, error } = listState.get()
if (error) {
return html`<div class="error"><p>${error}</p><button onClick=${() => goToPage(page)}>Retry</button></div>`
}
if (isLoading && !result) return html`<p>Loading posts...</p>`
return html`
<div class="post-list">
${result!.data.map((post) => html`
<article class="post-card">
<h2>${post.title}</h2>
<p>${post.body}</p>
</article>
`)}
<div class="pagination">
<button disabled=${page === 1 || isLoading} onClick=${() => goToPage(page - 1)}>Previous</button>
<span>Page ${page} of ${Math.ceil(result!.total / pageSize)}</span>
<button disabled=${!result!.hasMore || isLoading} onClick=${() => goToPage(page + 1)}>Next</button>
</div>
</div>
`
}
// render(html`<${PostList} />`, '#app'); goToPage(1)
src/components/InfiniteScroll.ts
import { html, createState } from '@_bashell/slash'
import { loadPosts } from '../loaders/posts'
import type { Post } from '../types'
const feed = createState<{ items: Post[]; page: number; hasMore: boolean; isLoading: boolean }>({
items: [],
page: 0,
hasMore: true,
isLoading: false
})
export const loadMore = async () => {
const current = feed.get()
if (current.isLoading || !current.hasMore) return
feed.set({ ...current, isLoading: true })
const nextPage = current.page + 1
try {
const result = await loadPosts(nextPage, 10)
const latest = feed.get()
feed.set({
items: [...latest.items, ...result.data],
page: nextPage,
hasMore: result.hasMore,
isLoading: false
})
} catch (error) {
console.error('Failed to load more posts:', error)
} finally {
// Garante que isLoading não fique preso em true se a requisição falhar
feed.set({ ...feed.get(), isLoading: false })
}
}
export const InfiniteScroll = () => {
const { items, hasMore, isLoading } = feed.get()
return html`
<div class="feed">
${items.map((post) => html`<article class="post-card"><h3>${post.title}</h3></article>`)}
${isLoading ? html`<p>Loading...</p>` : null}
${!hasMore ? html`<p>No more posts</p>` : null}
</div>
`
}
// Um listener global fica fora do componente
window.addEventListener('scroll', () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200
if (nearBottom) loadMore()
})
// render(html`<${InfiniteScroll} />`, '#app'); loadMore()

Quando uma consulta depende do resultado de outra, encadeie no código que dispara o carregamento:

src/components/UserProfile.ts
import { html } from '@_bashell/slash'
import { createQuery } from '../query'
import type { User, Post } from '../types'
let currentUserId = '1'
// fetchUser e fetchPostsByUser são as suas funções de acesso à API
const user = createQuery<User>(() => fetchUser(currentUserId))
const posts = createQuery<Post[]>(() => fetchPostsByUser(user.state.get().data!.id))
export const loadProfile = async (userId: string) => {
currentUserId = userId
await user.load()
if (user.state.get().data) await posts.load() // só depois que o usuário chegou
}
export const UserProfile = () => {
// Estes get() inscrevem o componente
const u = user.state.get()
const p = posts.state.get()
if (u.isLoading) return html`<p>Loading user...</p>`
if (u.error) return html`<p class="error">${u.error.message}</p>`
return html`
<div class="profile">
<h1>${u.data!.name}</h1>
<h2>Posts</h2>
${p.isLoading
? html`<p>Loading posts...</p>`
: p.error
? html`<p class="error">${p.error.message}</p>`
: html`<ul>${p.data!.map((post) => html`<li>${post.title}</li>`)}</ul>`}
</div>
`
}
// render(html`<${UserProfile} />`, '#app'); loadProfile('1')

Dispare consultas independentes ao mesmo tempo:

src/components/Dashboard.ts
import { html } from '@_bashell/slash'
import { createQuery } from '../query'
import { loadPosts } from '../loaders/posts'
import type { User, Comment } from '../types'
// Funções simples de fetch para este exemplo
const fetchUsers = async (_page: number, limit: number): Promise<User[]> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users?_limit=${limit}`)
if (!response.ok) throw new Error('Failed to fetch users')
return response.json()
}
const fetchComments = async (_page: number, limit: number): Promise<Comment[]> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/comments?_limit=${limit}`)
if (!response.ok) throw new Error('Failed to fetch comments')
return response.json()
}
const posts = createQuery(() => loadPosts(1, 5))
const users = createQuery(() => fetchUsers(1, 5))
const comments = createQuery(() => fetchComments(1, 10))
export const loadDashboard = () =>
Promise.all([posts.load(), users.load(), comments.load()])
export const Dashboard = () => {
// Os três get() inscrevem o Dashboard: ele re-renderiza quando qualquer um mudar
const p = posts.state.get()
const u = users.state.get()
const c = comments.state.get()
const allLoaded = !p.isLoading && !u.isLoading && !c.isLoading
return html`
<div class="dashboard">
${allLoaded
? html`<p>${p.data?.data.length} posts, ${u.data?.length} users, ${c.data?.length} comments</p>`
: html`<p>Loading dashboard...</p>`}
</div>
`
}
// render(html`<${Dashboard} />`, '#app'); loadDashboard()

Atualize a interface na hora e reverta se a requisição falhar:

src/components/OptimisticPost.ts
import { html, createState } from '@_bashell/slash'
import { updatePost } from '../loaders/posts'
import type { Post } from '../types'
const post = createState<Post>({ id: '1', title: 'Original title', body: '...', userId: '1', createdAt: '' })
const status = createState<{ saving: boolean; error: string | null }>({ saving: false, error: null })
export const rename = async (title: string) => {
const previous = post.get()
post.set({ ...previous, title }) // 1. otimista: mostra já
status.set({ saving: true, error: null })
try {
await updatePost(previous.id, { title }) // 2. confirma no servidor
status.set({ saving: false, error: null })
} catch (error) {
post.set(previous) // 3. falhou: reverte
status.set({ saving: false, error: (error as Error).message })
}
}
export const OptimisticPost = () => {
const current = post.get()
const { saving, error } = status.get()
return html`
<div>
<h1>${current.title}</h1>
${saving ? html`<small>Saving...</small>` : null}
${error ? html`<p class="error">${error}</p>` : null}
<button onClick=${() => rename('New title ' + Date.now())}>Rename</button>
</div>
`
}

Pré-carregar ao passar o mouse aquece o cache do loader; a navegação seguinte vem do cache:

import { html } from '@_bashell/slash'
import { postLoader } from '../loaders/posts'
import type { Post } from '../types'
const prefetch = (postId: string) => {
// O resultado fica em cache por key + params pelo ttl do loader
postLoader({ params: { id: postId }, isServer: false }).catch(() => {})
}
export const PostLink = ({ post }: { post: Post }) => html`
<a
href=${`/posts/${post.id}`}
onMouseEnter=${() => prefetch(post.id)}
>
${post.title}
</a>
`
.skeleton-card {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 8px;
height: 200px;
margin-bottom: 1rem;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Terminal window
# Desenvolvimento
bun run dev
# Build
bun run build
createLoader(fetchFn, {
key: 'static-data',
ttl: 60 * 60 * 1000 // 1 hora para dados estáticos
})

Use revalidate: true nas opções para ignorar o cache e buscar de novo.

O cache guarda o resultado depois que a requisição termina. Duas chamadas simultâneas, antes do primeiro resultado, disparam duas requisições. Se isso importar, compartilhe a Promise em andamento no seu código.

// Ruim: invalida tudo
invalidateLoader()
// Bom: invalida apenas o loader afetado (pela key)
invalidateLoader('post')

invalidateLoader(key) remove todas as entradas daquele loader (todos os params); não há invalidação por parâmetro individual.

  1. Loaders Isomórficos: o mesmo código no servidor e no cliente
  2. Cache com TTL: key, ttl e invalidateLoader(key)
  3. State fora do componente: componentes leem state.get() e re-renderizam; o fetch é disparado fora deles
  4. Optimistic Updates: feedback imediato com reversão
  5. Performance: carregamento em paralelo e prefetching