Data Fetching Patterns
Data Fetching Patterns
Section titled “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.
Funcionalidades
Section titled “Funcionalidades”- ✅ 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
Estrutura do Projeto
Section titled “Estrutura do Projeto”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 # TiposTipos TypeScript
Section titled “Tipos TypeScript”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}Loader Básico
Section titled “Loader Básico”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.
import { createLoader, invalidateLoader } from '@_bashell/slash'import type { Post, PaginatedResponse } from '../types'
// Loader de lista de posts com paginaçãoexport 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 individualexport 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 clienteexport 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')}Controlador de Consulta
Section titled “Controlador de Consulta”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:
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()Paginação Tradicional
Section titled “Paginação Tradicional”import { html, createState, batch } from '@_bashell/slash'import { loadPosts } from '../loaders/posts'import type { Post, PaginatedResponse } from '../types'
const pageSize = 10
// States fora do componenteconst 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)Infinite Scroll
Section titled “Infinite Scroll”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 componentewindow.addEventListener('scroll', () => { const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200 if (nearBottom) loadMore()})
// render(html`<${InfiniteScroll} />`, '#app'); loadMore()Dependent Queries
Section titled “Dependent Queries”Quando uma consulta depende do resultado de outra, encadeie no código que dispara o carregamento:
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 à APIconst 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')Parallel Loading
Section titled “Parallel Loading”Dispare consultas independentes ao mesmo tempo:
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 exemploconst 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()Optimistic Updates
Section titled “Optimistic Updates”Atualize a interface na hora e reverta se a requisição falhar:
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> `}Prefetching
Section titled “Prefetching”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 Screens
Section titled “Skeleton Screens”.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; }}Executando o Projeto
Section titled “Executando o Projeto”# Desenvolvimentobun run dev
# Buildbun run buildPerformance Tips
Section titled “Performance Tips”1. Cache com TTL
Section titled “1. Cache com TTL”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.
2. Sem deduplicação automática
Section titled “2. Sem deduplicação automática”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.
3. Invalidação Granular
Section titled “3. Invalidação Granular”// Ruim: invalida tudoinvalidateLoader()
// 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.
Pontos-Chave de Aprendizado
Section titled “Pontos-Chave de Aprendizado”- Loaders Isomórficos: o mesmo código no servidor e no cliente
- Cache com TTL:
key,ttleinvalidateLoader(key) - State fora do componente: componentes leem
state.get()e re-renderizam; o fetch é disparado fora deles - Optimistic Updates: feedback imediato com reversão
- Performance: carregamento em paralelo e prefetching
Próximos Passos
Section titled “Próximos Passos”- Universal Data Loading - Documentação completa
- SSR - Server-Side Rendering patterns
- State Management - Gerenciamento de estado