Features: - Zero configuration, just works out of the box - Runtime type inference and validation - Built-in caching with W-TinyLFU algorithm - Automatic retries with exponential backoff - Circuit breaker for resilience - Request deduplication - Offline support with queue - OpenAPI schema discovery - Full TypeScript support with type descriptors - Modular architecture - Configurable for advanced use cases Built with bun, ready for npm publishing
64 KiB
Chapter 14: Framework Integration
"A great library plays well with others."
The Framework Dilemma
Sarah's Weather Buddy was a massive success. So successful that other teams wanted to use her weather API abstractions in their apps.
"We use React," said the mobile team. "Vue.js here," chimed the dashboard team. "Svelte for us," added the innovation lab. "Don't forget Angular!" shouted enterprise.
Sarah groaned. Did she need to rewrite everything for each framework?
"Absolutely not," Marcus smiled. "TypedFetch is framework-agnostic by design. Let me show you how to create beautiful integrations that feel native to each framework."
React Integration: Hooks All The Way
React developers love hooks. Let's give them what they want:
// typedfetch-react/src/hooks.ts
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { TypedFetch, RequestConfig, CacheEntry } from 'typedfetch'
// Core hook for data fetching
export function useTypedFetch<T>(
url: string | (() => string | null),
options?: RequestConfig & {
skip?: boolean
refetchInterval?: number
refetchOnWindowFocus?: boolean
refetchOnReconnect?: boolean
}
) {
const [data, setData] = useState<T | undefined>()
const [error, setError] = useState<Error | undefined>()
const [loading, setLoading] = useState(false)
const [isValidating, setIsValidating] = useState(false)
const abortControllerRef = useRef<AbortController>()
const mountedRef = useRef(true)
// Memoize URL
const resolvedUrl = useMemo(() => {
if (typeof url === 'function') {
return url()
}
return url
}, [url])
// Fetch function
const fetchData = useCallback(async (isRevalidation = false) => {
if (!resolvedUrl || options?.skip) return
// Cancel previous request
abortControllerRef.current?.abort()
abortControllerRef.current = new AbortController()
try {
if (isRevalidation) {
setIsValidating(true)
} else {
setLoading(true)
setError(undefined)
}
const response = await tf.get(resolvedUrl, {
...options,
signal: abortControllerRef.current.signal
})
if (mountedRef.current) {
setData(response.data)
setError(undefined)
}
} catch (err) {
if (mountedRef.current && err.name !== 'AbortError') {
setError(err as Error)
if (!isRevalidation) {
setData(undefined)
}
}
} finally {
if (mountedRef.current) {
setLoading(false)
setIsValidating(false)
}
}
}, [resolvedUrl, options])
// Initial fetch
useEffect(() => {
fetchData()
}, [fetchData])
// Cleanup
useEffect(() => {
return () => {
mountedRef.current = false
abortControllerRef.current?.abort()
}
}, [])
// Refetch interval
useEffect(() => {
if (!options?.refetchInterval) return
const interval = setInterval(() => {
fetchData(true)
}, options.refetchInterval)
return () => clearInterval(interval)
}, [options?.refetchInterval, fetchData])
// Window focus refetch
useEffect(() => {
if (!options?.refetchOnWindowFocus) return
const handleFocus = () => fetchData(true)
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [options?.refetchOnWindowFocus, fetchData])
// Reconnect refetch
useEffect(() => {
if (!options?.refetchOnReconnect) return
const handleOnline = () => fetchData(true)
window.addEventListener('online', handleOnline)
return () => window.removeEventListener('online', handleOnline)
}, [options?.refetchOnReconnect, fetchData])
// Manual refetch
const refetch = useCallback(() => {
return fetchData()
}, [fetchData])
// Mutate local data
const mutate = useCallback((newData: T | ((prev: T | undefined) => T)) => {
if (typeof newData === 'function') {
setData(prev => (newData as Function)(prev))
} else {
setData(newData)
}
}, [])
return {
data,
error,
loading,
isValidating,
refetch,
mutate,
isError: !!error,
isSuccess: !!data && !error
}
}
// Mutation hook
export function useTypedMutation<TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<{ data: TData }>
) {
const [data, setData] = useState<TData | undefined>()
const [error, setError] = useState<Error | undefined>()
const [loading, setLoading] = useState(false)
const mutate = useCallback(async (
variables: TVariables,
options?: {
onSuccess?: (data: TData) => void
onError?: (error: Error) => void
onSettled?: () => void
}
) => {
try {
setLoading(true)
setError(undefined)
const response = await mutationFn(variables)
const responseData = response.data
setData(responseData)
options?.onSuccess?.(responseData)
return responseData
} catch (err) {
const error = err as Error
setError(error)
options?.onError?.(error)
throw error
} finally {
setLoading(false)
options?.onSettled?.()
}
}, [mutationFn])
const reset = useCallback(() => {
setData(undefined)
setError(undefined)
setLoading(false)
}, [])
return {
mutate,
mutateAsync: mutate,
data,
error,
loading,
isError: !!error,
isSuccess: !!data && !error,
reset
}
}
// Infinite scroll hook
export function useInfiniteTypedFetch<T>(
getUrl: (pageParam: number) => string,
options?: RequestConfig & {
initialPageParam?: number
getNextPageParam?: (lastPage: T, allPages: T[]) => number | undefined
}
) {
const [pages, setPages] = useState<T[]>([])
const [error, setError] = useState<Error | undefined>()
const [isLoading, setIsLoading] = useState(false)
const [isFetchingNextPage, setIsFetchingNextPage] = useState(false)
const [hasNextPage, setHasNextPage] = useState(true)
const currentPageRef = useRef(options?.initialPageParam ?? 0)
const fetchNextPage = useCallback(async () => {
if (!hasNextPage || isFetchingNextPage) return
try {
setIsFetchingNextPage(true)
const url = getUrl(currentPageRef.current)
const response = await tf.get<T>(url, options)
const newPage = response.data
setPages(prev => [...prev, newPage])
// Determine next page
const nextPageParam = options?.getNextPageParam?.(
newPage,
[...pages, newPage]
) ?? currentPageRef.current + 1
if (nextPageParam === undefined) {
setHasNextPage(false)
} else {
currentPageRef.current = nextPageParam
}
} catch (err) {
setError(err as Error)
} finally {
setIsFetchingNextPage(false)
setIsLoading(false)
}
}, [getUrl, hasNextPage, isFetchingNextPage, options, pages])
// Initial fetch
useEffect(() => {
if (pages.length === 0) {
setIsLoading(true)
fetchNextPage()
}
}, [])
const refetch = useCallback(async () => {
setPages([])
setError(undefined)
setHasNextPage(true)
currentPageRef.current = options?.initialPageParam ?? 0
setIsLoading(true)
await fetchNextPage()
}, [fetchNextPage, options?.initialPageParam])
return {
data: pages,
error,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
refetch,
isError: !!error,
isSuccess: pages.length > 0 && !error
}
}
// Prefetch hook
export function usePrefetch() {
return useCallback((url: string, options?: RequestConfig) => {
// Prefetch in background
tf.get(url, { ...options, priority: 'low' }).catch(() => {
// Silently fail prefetch
})
}, [])
}
// SSR-safe hook
export function useSSRTypedFetch<T>(
url: string,
initialData?: T,
options?: RequestConfig
) {
const [data, setData] = useState<T | undefined>(initialData)
const [error, setError] = useState<Error | undefined>()
const [loading, setLoading] = useState(!initialData)
useEffect(() => {
// Skip if we have initial data from SSR
if (initialData) return
let cancelled = false
const fetchData = async () => {
try {
setLoading(true)
const response = await tf.get<T>(url, options)
if (!cancelled) {
setData(response.data)
}
} catch (err) {
if (!cancelled) {
setError(err as Error)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
cancelled = true
}
}, [url, initialData, options])
return { data, error, loading }
}
// Cache management hooks
export function useTypedFetchCache() {
const invalidate = useCallback((pattern?: string | RegExp) => {
if (pattern) {
tf.cache.invalidate(pattern)
} else {
tf.cache.clear()
}
}, [])
const prefetch = useCallback((url: string, options?: RequestConfig) => {
return tf.get(url, { ...options, priority: 'low' })
}, [])
const getCacheEntry = useCallback((url: string): CacheEntry | undefined => {
return tf.cache.get(url)
}, [])
return {
invalidate,
prefetch,
getCacheEntry
}
}
// Real-time subscription hook
export function useTypedFetchSubscription<T>(
url: string,
options?: {
onMessage?: (data: T) => void
onError?: (error: Error) => void
onClose?: () => void
}
) {
const [data, setData] = useState<T | undefined>()
const [error, setError] = useState<Error | undefined>()
const [connected, setConnected] = useState(false)
const streamRef = useRef<EventSource | WebSocket>()
useEffect(() => {
const stream = tf.stream<T>(url)
streamRef.current = stream
stream.on('open', () => setConnected(true))
stream.on('message', (event: T) => {
setData(event)
options?.onMessage?.(event)
})
stream.on('error', (err: Error) => {
setError(err)
options?.onError?.(err)
})
stream.on('close', () => {
setConnected(false)
options?.onClose?.()
})
return () => {
stream.close()
}
}, [url, options])
const close = useCallback(() => {
streamRef.current?.close()
}, [])
return {
data,
error,
connected,
close
}
}
React Component Examples
Now let's see these hooks in action:
// WeatherApp.tsx
import React from 'react'
import { useTypedFetch, useTypedMutation, usePrefetch } from 'typedfetch-react'
function WeatherDisplay({ city }: { city: string }) {
const { data, loading, error, refetch, isValidating } = useTypedFetch<Weather>(
`/api/weather/${city}`,
{
refetchInterval: 60000, // Refetch every minute
refetchOnWindowFocus: true,
refetchOnReconnect: true
}
)
const prefetch = usePrefetch()
// Prefetch nearby cities
React.useEffect(() => {
if (data?.nearbyCities) {
data.nearbyCities.forEach(nearby => {
prefetch(`/api/weather/${nearby}`)
})
}
}, [data, prefetch])
if (loading) return <LoadingSpinner />
if (error) return <ErrorDisplay error={error} onRetry={refetch} />
return (
<div className="weather-card">
<h2>{data.city}</h2>
<div className="temperature">{data.temperature}°C</div>
<div className="condition">{data.condition}</div>
{isValidating && <RefreshIndicator />}
<button onClick={refetch}>Refresh</button>
</div>
)
}
function FavoriteButton({ city }: { city: string }) {
const { mutate, loading } = useTypedMutation<
{ success: boolean },
{ city: string }
>(
(variables) => tf.post('/api/favorites', { data: variables })
)
const handleClick = () => {
mutate(
{ city },
{
onSuccess: () => {
toast.success(`${city} added to favorites!`)
},
onError: (error) => {
toast.error(`Failed: ${error.message}`)
}
}
)
}
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Adding...' : 'Add to Favorites'}
</button>
)
}
function WeatherFeed() {
const {
data: pages,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteTypedFetch<WeatherPage>(
(pageParam) => `/api/weather/feed?page=${pageParam}`,
{
getNextPageParam: (lastPage) => lastPage.nextPage
}
)
return (
<div>
{pages.map((page, i) => (
<React.Fragment key={i}>
{page.items.map(weather => (
<WeatherCard key={weather.id} weather={weather} />
))}
</React.Fragment>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
)
}
// Real-time weather updates
function LiveWeather({ city }: { city: string }) {
const { data, connected, error } = useTypedFetchSubscription<WeatherUpdate>(
`/api/weather/${city}/live`,
{
onMessage: (update) => {
console.log('Weather update:', update)
}
}
)
return (
<div className="live-weather">
<ConnectionStatus connected={connected} />
{error && <ErrorAlert error={error} />}
{data && (
<div className="live-update">
<span className="temperature">{data.temperature}°C</span>
<span className="time">{data.timestamp}</span>
</div>
)}
</div>
)
}
Vue.js Integration: Composition API Magic
Vue developers love the Composition API. Let's create composables:
// typedfetch-vue/src/composables.ts
import { ref, computed, watch, onUnmounted, Ref, unref, isRef } from 'vue'
import type { WatchSource } from 'vue'
import { TypedFetch, RequestConfig } from 'typedfetch'
// Core composable
export function useTypedFetch<T>(
url: string | Ref<string | null> | (() => string | null),
options?: RequestConfig & {
immediate?: boolean
watch?: boolean
refetchOnWindowFocus?: boolean
transform?: (data: any) => T
}
) {
const data = ref<T>()
const error = ref<Error>()
const loading = ref(false)
const isValidating = ref(false)
let abortController: AbortController | undefined
const execute = async (isRevalidation = false) => {
const resolvedUrl = typeof url === 'function'
? url()
: unref(url)
if (!resolvedUrl) return
// Cancel previous request
abortController?.abort()
abortController = new AbortController()
try {
if (isRevalidation) {
isValidating.value = true
} else {
loading.value = true
error.value = undefined
}
const response = await tf.get(resolvedUrl, {
...options,
signal: abortController.signal
})
data.value = options?.transform
? options.transform(response.data)
: response.data
error.value = undefined
} catch (err) {
if (err.name !== 'AbortError') {
error.value = err as Error
if (!isRevalidation) {
data.value = undefined
}
}
} finally {
loading.value = false
isValidating.value = false
}
}
const refetch = () => execute()
// Auto-execute
if (options?.immediate !== false) {
execute()
}
// Watch URL changes
if (options?.watch !== false && (isRef(url) || typeof url === 'function')) {
const watchSource = typeof url === 'function'
? url as WatchSource
: url as Ref
watch(watchSource, () => {
execute()
})
}
// Window focus refetch
if (options?.refetchOnWindowFocus) {
const handleFocus = () => execute(true)
window.addEventListener('focus', handleFocus)
onUnmounted(() => {
window.removeEventListener('focus', handleFocus)
})
}
// Cleanup
onUnmounted(() => {
abortController?.abort()
})
return {
data: computed(() => data.value),
error: computed(() => error.value),
loading: computed(() => loading.value),
isValidating: computed(() => isValidating.value),
execute,
refetch,
abort: () => abortController?.abort()
}
}
// Mutation composable
export function useTypedMutation<TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<{ data: TData }>
) {
const data = ref<TData>()
const error = ref<Error>()
const loading = ref(false)
const mutate = async (
variables: TVariables,
options?: {
onSuccess?: (data: TData) => void
onError?: (error: Error) => void
onSettled?: () => void
}
) => {
try {
loading.value = true
error.value = undefined
const response = await mutationFn(variables)
const responseData = response.data
data.value = responseData
options?.onSuccess?.(responseData)
return responseData
} catch (err) {
const errorObj = err as Error
error.value = errorObj
options?.onError?.(errorObj)
throw errorObj
} finally {
loading.value = false
options?.onSettled?.()
}
}
const reset = () => {
data.value = undefined
error.value = undefined
loading.value = false
}
return {
data: computed(() => data.value),
error: computed(() => error.value),
loading: computed(() => loading.value),
mutate,
reset
}
}
// Reactive TypedFetch instance
export function useTypedFetchInstance(config?: RequestConfig) {
const instance = ref(tf.create(config))
const updateConfig = (newConfig: Partial<RequestConfig>) => {
instance.value = tf.create({
...instance.value.defaults,
...newConfig
})
}
return {
instance: computed(() => instance.value),
updateConfig
}
}
// Pagination composable
export function usePagination<T>(
baseUrl: string,
options?: {
pageSize?: number
pageParam?: string
transform?: (data: any) => T[]
}
) {
const currentPage = ref(1)
const pageSize = ref(options?.pageSize || 10)
const totalPages = ref(0)
const totalItems = ref(0)
const url = computed(() => {
const params = new URLSearchParams({
[options?.pageParam || 'page']: currentPage.value.toString(),
pageSize: pageSize.value.toString()
})
return `${baseUrl}?${params}`
})
const { data, loading, error, refetch } = useTypedFetch<{
items: T[]
total: number
page: number
pageSize: number
}>(url)
const items = computed(() => {
if (!data.value) return []
return options?.transform
? options.transform(data.value.items)
: data.value.items
})
watch(data, (newData) => {
if (newData) {
totalItems.value = newData.total
totalPages.value = Math.ceil(newData.total / newData.pageSize)
}
})
const goToPage = (page: number) => {
currentPage.value = Math.max(1, Math.min(page, totalPages.value))
}
const nextPage = () => goToPage(currentPage.value + 1)
const prevPage = () => goToPage(currentPage.value - 1)
const hasPrev = computed(() => currentPage.value > 1)
const hasNext = computed(() => currentPage.value < totalPages.value)
return {
items,
loading,
error,
currentPage: computed(() => currentPage.value),
pageSize: computed(() => pageSize.value),
totalPages: computed(() => totalPages.value),
totalItems: computed(() => totalItems.value),
hasPrev,
hasNext,
goToPage,
nextPage,
prevPage,
refetch,
setPageSize: (size: number) => {
pageSize.value = size
currentPage.value = 1
}
}
}
// Form handling composable
export function useForm<T extends Record<string, any>>(
initialValues: T,
options?: {
onSubmit?: (values: T) => Promise<any>
validate?: (values: T) => Record<string, string>
}
) {
const values = ref<T>({ ...initialValues })
const errors = ref<Record<string, string>>({})
const touched = ref<Record<string, boolean>>({})
const submitting = ref(false)
const handleChange = (field: keyof T, value: any) => {
values.value[field] = value
touched.value[field as string] = true
// Clear error on change
if (errors.value[field as string]) {
delete errors.value[field as string]
}
}
const handleBlur = (field: keyof T) => {
touched.value[field as string] = true
validateField(field)
}
const validateField = (field: keyof T) => {
if (options?.validate) {
const fieldErrors = options.validate(values.value)
if (fieldErrors[field as string]) {
errors.value[field as string] = fieldErrors[field as string]
}
}
}
const validateForm = () => {
if (options?.validate) {
const formErrors = options.validate(values.value)
errors.value = formErrors
return Object.keys(formErrors).length === 0
}
return true
}
const handleSubmit = async (e?: Event) => {
e?.preventDefault()
if (!validateForm()) return
if (options?.onSubmit) {
try {
submitting.value = true
await options.onSubmit(values.value)
} finally {
submitting.value = false
}
}
}
const reset = () => {
values.value = { ...initialValues }
errors.value = {}
touched.value = {}
submitting.value = false
}
const setFieldValue = (field: keyof T, value: any) => {
values.value[field] = value
}
const setFieldError = (field: keyof T, error: string) => {
errors.value[field as string] = error
}
return {
values: computed(() => values.value),
errors: computed(() => errors.value),
touched: computed(() => touched.value),
submitting: computed(() => submitting.value),
handleChange,
handleBlur,
handleSubmit,
reset,
setFieldValue,
setFieldError,
isValid: computed(() => Object.keys(errors.value).length === 0)
}
}
// SSE composable
export function useServerSentEvents<T>(
url: string | Ref<string>,
options?: {
immediate?: boolean
onMessage?: (event: T) => void
onError?: (error: Error) => void
}
) {
const data = ref<T>()
const error = ref<Error>()
const connected = ref(false)
let eventSource: EventSource | null = null
const connect = () => {
const resolvedUrl = unref(url)
if (!resolvedUrl || eventSource) return
eventSource = tf.sse(resolvedUrl)
eventSource.onopen = () => {
connected.value = true
error.value = undefined
}
eventSource.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as T
data.value = parsed
options?.onMessage?.(parsed)
} catch (err) {
error.value = new Error('Failed to parse SSE data')
}
}
eventSource.onerror = (err) => {
connected.value = false
error.value = new Error('SSE connection error')
options?.onError?.(error.value)
}
}
const disconnect = () => {
eventSource?.close()
eventSource = null
connected.value = false
}
// Auto-connect
if (options?.immediate !== false) {
connect()
}
// Watch URL changes
if (isRef(url)) {
watch(url, (newUrl, oldUrl) => {
if (newUrl !== oldUrl) {
disconnect()
if (newUrl) connect()
}
})
}
// Cleanup
onUnmounted(() => {
disconnect()
})
return {
data: computed(() => data.value),
error: computed(() => error.value),
connected: computed(() => connected.value),
connect,
disconnect
}
}
Vue Component Examples
Using the composables in Vue components:
<!-- WeatherDisplay.vue -->
<template>
<div class="weather-display">
<div v-if="loading" class="loading">
<LoadingSpinner />
</div>
<div v-else-if="error" class="error">
<ErrorDisplay :error="error" @retry="refetch" />
</div>
<div v-else-if="data" class="weather-card">
<h2>{{ data.city }}</h2>
<div class="temperature">{{ data.temperature }}°C</div>
<div class="condition">{{ data.condition }}</div>
<RefreshIndicator v-if="isValidating" />
<button @click="refetch" :disabled="isValidating">
Refresh
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useTypedFetch } from 'typedfetch-vue'
const props = defineProps<{
city: string
}>()
const { data, loading, error, refetch, isValidating } = useTypedFetch<Weather>(
() => `/api/weather/${props.city}`,
{
refetchOnWindowFocus: true,
transform: (data) => ({
...data,
temperature: Math.round(data.temperature)
})
}
)
</script>
<!-- WeatherForm.vue -->
<template>
<form @submit="handleSubmit">
<div class="form-group">
<label>City Name</label>
<input
:value="values.city"
@input="handleChange('city', $event.target.value)"
@blur="handleBlur('city')"
:class="{ error: errors.city && touched.city }"
/>
<span v-if="errors.city && touched.city" class="error-message">
{{ errors.city }}
</span>
</div>
<div class="form-group">
<label>Temperature Unit</label>
<select
:value="values.unit"
@change="handleChange('unit', $event.target.value)"
>
<option value="celsius">Celsius</option>
<option value="fahrenheit">Fahrenheit</option>
</select>
</div>
<button type="submit" :disabled="submitting || !isValid">
{{ submitting ? 'Adding...' : 'Add City' }}
</button>
</form>
</template>
<script setup lang="ts">
import { useForm, useTypedMutation } from 'typedfetch-vue'
const { mutate } = useTypedMutation<
{ id: string; city: string },
{ city: string; unit: string }
>(
(variables) => tf.post('/api/cities', { data: variables })
)
const {
values,
errors,
touched,
submitting,
handleChange,
handleBlur,
handleSubmit,
isValid
} = useForm(
{ city: '', unit: 'celsius' },
{
validate: (values) => {
const errors: Record<string, string> = {}
if (!values.city) {
errors.city = 'City is required'
} else if (values.city.length < 2) {
errors.city = 'City must be at least 2 characters'
}
return errors
},
onSubmit: async (values) => {
const result = await mutate(values, {
onSuccess: (data) => {
console.log('City added:', data)
// Navigate or show success
},
onError: (error) => {
console.error('Failed to add city:', error)
}
})
}
}
)
</script>
<!-- WeatherList.vue -->
<template>
<div class="weather-list">
<h2>Weather Feed</h2>
<div class="filters">
<input
v-model="searchTerm"
placeholder="Search cities..."
@input="debouncedSearch"
/>
<select v-model="pageSize" @change="setPageSize(pageSize)">
<option :value="10">10 per page</option>
<option :value="25">25 per page</option>
<option :value="50">50 per page</option>
</select>
</div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">
Error: {{ error.message }}
</div>
<div v-else class="cities">
<WeatherCard
v-for="city in items"
:key="city.id"
:city="city"
/>
</div>
<div class="pagination">
<button
@click="prevPage"
:disabled="!hasPrev || loading"
>
Previous
</button>
<span>
Page {{ currentPage }} of {{ totalPages }}
({{ totalItems }} total)
</span>
<button
@click="nextPage"
:disabled="!hasNext || loading"
>
Next
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePagination } from 'typedfetch-vue'
import { debounce } from 'lodash-es'
const searchTerm = ref('')
const pageSize = ref(10)
const baseUrl = computed(() => {
const params = new URLSearchParams()
if (searchTerm.value) {
params.set('search', searchTerm.value)
}
return `/api/weather/cities${params.toString() ? '?' + params : ''}`
})
const {
items,
loading,
error,
currentPage,
totalPages,
totalItems,
hasPrev,
hasNext,
goToPage,
nextPage,
prevPage,
setPageSize
} = usePagination<City>(baseUrl, {
pageSize: pageSize.value
})
const debouncedSearch = debounce(() => {
goToPage(1) // Reset to first page on search
}, 300)
</script>
<!-- LiveWeather.vue -->
<template>
<div class="live-weather">
<h3>Live Updates for {{ city }}</h3>
<ConnectionStatus :connected="connected" />
<div v-if="error" class="error">
Connection error: {{ error.message }}
</div>
<div v-if="data" class="live-data">
<div class="metric">
<span class="label">Temperature:</span>
<span class="value">{{ data.temperature }}°C</span>
</div>
<div class="metric">
<span class="label">Wind:</span>
<span class="value">{{ data.windSpeed }} km/h</span>
</div>
<div class="metric">
<span class="label">Updated:</span>
<span class="value">{{ formatTime(data.timestamp) }}</span>
</div>
</div>
<button v-if="!connected" @click="connect">
Reconnect
</button>
</div>
</template>
<script setup lang="ts">
import { useServerSentEvents } from 'typedfetch-vue'
const props = defineProps<{
city: string
}>()
const { data, error, connected, connect } = useServerSentEvents<LiveWeatherData>(
`/api/weather/${props.city}/live`,
{
onMessage: (event) => {
console.log('Live update:', event)
}
}
)
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString()
}
</script>
Svelte Integration: Stores and Actions
Svelte's reactive stores are perfect for TypedFetch:
// typedfetch-svelte/src/stores.ts
import { writable, derived, readable } from 'svelte/store'
import type { Readable, Writable } from 'svelte/store'
import { TypedFetch, RequestConfig } from 'typedfetch'
// Fetch store factory
export function createFetchStore<T>(
url: string | (() => string | null),
options?: RequestConfig & {
refetchInterval?: number
refetchOnFocus?: boolean
}
) {
const data = writable<T | undefined>(undefined)
const error = writable<Error | undefined>(undefined)
const loading = writable(false)
let abortController: AbortController | undefined
let interval: number | undefined
const execute = async () => {
const resolvedUrl = typeof url === 'function' ? url() : url
if (!resolvedUrl) return
abortController?.abort()
abortController = new AbortController()
loading.set(true)
error.set(undefined)
try {
const response = await tf.get<T>(resolvedUrl, {
...options,
signal: abortController.signal
})
data.set(response.data)
} catch (err) {
if (err.name !== 'AbortError') {
error.set(err as Error)
}
} finally {
loading.set(false)
}
}
// Initial fetch
execute()
// Setup refetch interval
if (options?.refetchInterval) {
interval = setInterval(execute, options.refetchInterval)
}
// Window focus refetch
if (options?.refetchOnFocus) {
const handleFocus = () => execute()
window.addEventListener('focus', handleFocus)
// Return cleanup function
const originalDestroy = data.subscribe(() => {})
data.subscribe = (run, invalidate) => {
const unsubscribe = writable.prototype.subscribe.call(data, run, invalidate)
return () => {
unsubscribe()
window.removeEventListener('focus', handleFocus)
if (interval) clearInterval(interval)
abortController?.abort()
}
}
}
return {
data: { subscribe: data.subscribe } as Readable<T | undefined>,
error: { subscribe: error.subscribe } as Readable<Error | undefined>,
loading: { subscribe: loading.subscribe } as Readable<boolean>,
refetch: execute
}
}
// Mutation store
export function createMutationStore<TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<{ data: TData }>
) {
const data = writable<TData | undefined>(undefined)
const error = writable<Error | undefined>(undefined)
const loading = writable(false)
const mutate = async (
variables: TVariables,
options?: {
onSuccess?: (data: TData) => void
onError?: (error: Error) => void
}
) => {
loading.set(true)
error.set(undefined)
try {
const response = await mutationFn(variables)
const responseData = response.data
data.set(responseData)
options?.onSuccess?.(responseData)
return responseData
} catch (err) {
const errorObj = err as Error
error.set(errorObj)
options?.onError?.(errorObj)
throw errorObj
} finally {
loading.set(false)
}
}
return {
data: { subscribe: data.subscribe } as Readable<TData | undefined>,
error: { subscribe: error.subscribe } as Readable<Error | undefined>,
loading: { subscribe: loading.subscribe } as Readable<boolean>,
mutate
}
}
// Derived stores for transformations
export function createDerivedFetchStore<T, U>(
url: string | (() => string | null),
transform: (data: T) => U,
options?: RequestConfig
) {
const store = createFetchStore<T>(url, options)
const derivedData = derived(
store.data,
$data => $data ? transform($data) : undefined
)
return {
...store,
data: derivedData
}
}
// Pagination store
export function createPaginationStore<T>(
baseUrl: string,
options?: {
pageSize?: number
transform?: (items: any[]) => T[]
}
) {
const currentPage = writable(1)
const pageSize = writable(options?.pageSize || 10)
const url = derived(
[currentPage, pageSize],
([$page, $size]) => `${baseUrl}?page=${$page}&pageSize=${$size}`
)
const { data, error, loading, refetch } = createFetchStore<{
items: T[]
total: number
page: number
pageSize: number
}>(
() => url.subscribe(value => value)()
)
const items = derived(
data,
$data => {
if (!$data) return []
return options?.transform
? options.transform($data.items)
: $data.items
}
)
const totalPages = derived(
data,
$data => $data ? Math.ceil($data.total / $data.pageSize) : 0
)
const hasPrev = derived(
currentPage,
$page => $page > 1
)
const hasNext = derived(
[currentPage, totalPages],
([$page, $total]) => $page < $total
)
return {
items,
error,
loading,
currentPage: { subscribe: currentPage.subscribe } as Readable<number>,
totalPages,
hasPrev,
hasNext,
goToPage: (page: number) => currentPage.set(page),
nextPage: () => currentPage.update(p => p + 1),
prevPage: () => currentPage.update(p => Math.max(1, p - 1)),
setPageSize: (size: number) => {
pageSize.set(size)
currentPage.set(1)
},
refetch
}
}
// WebSocket store
export function createWebSocketStore<T>(
url: string,
options?: {
protocols?: string | string[]
reconnect?: boolean
reconnectInterval?: number
}
) {
const messages = writable<T[]>([])
const connected = writable(false)
const error = writable<Error | undefined>(undefined)
let ws: WebSocket | null = null
let reconnectTimeout: number | undefined
const connect = () => {
if (ws?.readyState === WebSocket.OPEN) return
ws = tf.websocket(url, options?.protocols)
ws.onopen = () => {
connected.set(true)
error.set(undefined)
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as T
messages.update(msgs => [...msgs, data])
} catch (err) {
error.set(new Error('Failed to parse WebSocket message'))
}
}
ws.onerror = (event) => {
error.set(new Error('WebSocket error'))
}
ws.onclose = () => {
connected.set(false)
if (options?.reconnect !== false) {
reconnectTimeout = setTimeout(
connect,
options?.reconnectInterval || 5000
)
}
}
}
const send = (data: any) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
} else {
throw new Error('WebSocket is not connected')
}
}
const close = () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
}
ws?.close()
ws = null
}
// Auto-connect
connect()
return {
messages: { subscribe: messages.subscribe } as Readable<T[]>,
connected: { subscribe: connected.subscribe } as Readable<boolean>,
error: { subscribe: error.subscribe } as Readable<Error | undefined>,
send,
close,
connect
}
}
// Svelte actions
export function prefetch(node: HTMLElement, url: string) {
const handleMouseEnter = () => {
tf.get(url, { priority: 'low' }).catch(() => {})
}
node.addEventListener('mouseenter', handleMouseEnter)
return {
destroy() {
node.removeEventListener('mouseenter', handleMouseEnter)
}
}
}
export function infiniteScroll(
node: HTMLElement,
options: {
onLoadMore: () => void
threshold?: number
}
) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
options.onLoadMore()
}
},
{
rootMargin: `${options.threshold || 100}px`
}
)
observer.observe(node)
return {
destroy() {
observer.disconnect()
}
}
}
Svelte Component Examples
Using the stores in Svelte components:
<!-- WeatherDisplay.svelte -->
<script lang="ts">
import { createFetchStore } from 'typedfetch-svelte'
export let city: string
const { data, error, loading, refetch } = createFetchStore<Weather>(
`/api/weather/${city}`,
{
refetchInterval: 60000,
refetchOnFocus: true
}
)
</script>
<div class="weather-display">
{#if $loading}
<LoadingSpinner />
{:else if $error}
<ErrorDisplay error={$error} on:retry={refetch} />
{:else if $data}
<div class="weather-card">
<h2>{$data.city}</h2>
<div class="temperature">{$data.temperature}°C</div>
<div class="condition">{$data.condition}</div>
<button on:click={refetch}>Refresh</button>
</div>
{/if}
</div>
<!-- WeatherList.svelte -->
<script lang="ts">
import { createPaginationStore, infiniteScroll } from 'typedfetch-svelte'
const {
items,
loading,
error,
currentPage,
totalPages,
hasPrev,
hasNext,
goToPage,
nextPage,
prevPage,
setPageSize
} = createPaginationStore<Weather>('/api/weather/cities')
let pageSize = 10
$: setPageSize(pageSize)
</script>
<div class="weather-list">
<div class="controls">
<select bind:value={pageSize}>
<option value={10}>10 per page</option>
<option value={25}>25 per page</option>
<option value={50}>50 per page</option>
</select>
</div>
{#if $loading}
<LoadingIndicator />
{/if}
{#if $error}
<ErrorAlert error={$error} />
{/if}
<div class="cities">
{#each $items as city (city.id)}
<WeatherCard {city} />
{/each}
</div>
<div class="pagination">
<button
on:click={prevPage}
disabled={!$hasPrev || $loading}
>
Previous
</button>
<span>Page {$currentPage} of {$totalPages}</span>
<button
on:click={nextPage}
disabled={!$hasNext || $loading}
>
Next
</button>
</div>
<!-- For infinite scroll instead -->
<div
use:infiniteScroll={{
onLoadMore: nextPage,
threshold: 200
}}
/>
</div>
<!-- LiveWeather.svelte -->
<script lang="ts">
import { createWebSocketStore } from 'typedfetch-svelte'
export let city: string
const { messages, connected, error, send } = createWebSocketStore<WeatherUpdate>(
`/ws/weather/${city}`
)
$: latestUpdate = $messages[$messages.length - 1]
function requestUpdate() {
send({ type: 'REQUEST_UPDATE', city })
}
</script>
<div class="live-weather">
<h3>Live Updates for {city}</h3>
<ConnectionStatus {$connected} />
{#if $error}
<ErrorAlert error={$error} />
{/if}
{#if latestUpdate}
<div class="update">
<span class="temp">{latestUpdate.temperature}°C</span>
<span class="time">{new Date(latestUpdate.timestamp).toLocaleTimeString()}</span>
</div>
{/if}
<button on:click={requestUpdate} disabled={!$connected}>
Request Update
</button>
<div class="history">
<h4>Recent Updates</h4>
{#each $messages.slice(-10).reverse() as update}
<div class="update-item">
{update.temperature}°C at {new Date(update.timestamp).toLocaleTimeString()}
</div>
{/each}
</div>
</div>
<!-- WeatherForm.svelte -->
<script lang="ts">
import { createMutationStore } from 'typedfetch-svelte'
const { mutate, loading, error } = createMutationStore<
{ id: string; city: string },
{ city: string; unit: string }
>(
(variables) => tf.post('/api/cities', { data: variables })
)
let city = ''
let unit = 'celsius'
async function handleSubmit() {
if (!city) return
try {
const result = await mutate(
{ city, unit },
{
onSuccess: (data) => {
console.log('City added:', data)
city = '' // Reset form
}
}
)
} catch (err) {
console.error('Failed:', err)
}
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<input
bind:value={city}
placeholder="Enter city name"
disabled={$loading}
/>
<select bind:value={unit}>
<option value="celsius">Celsius</option>
<option value="fahrenheit">Fahrenheit</option>
</select>
<button type="submit" disabled={$loading || !city}>
{$loading ? 'Adding...' : 'Add City'}
</button>
{#if $error}
<div class="error">
Error: {$error.message}
</div>
{/if}
</form>
Angular Integration: Services and Observables
Angular loves RxJS, so let's embrace it:
// typedfetch-angular/src/services.ts
import { Injectable, inject } from '@angular/core'
import {
Observable,
from,
BehaviorSubject,
Subject,
throwError,
of,
interval,
fromEvent,
merge
} from 'rxjs'
import {
map,
catchError,
tap,
shareReplay,
switchMap,
retry,
retryWhen,
delay,
take,
filter,
distinctUntilChanged,
debounceTime,
startWith,
finalize
} from 'rxjs/operators'
import { TypedFetch, RequestConfig } from 'typedfetch'
@Injectable({
providedIn: 'root'
})
export class TypedFetchService {
private readonly tf = inject(TypedFetch)
get<T>(url: string, config?: RequestConfig): Observable<T> {
return from(this.tf.get<T>(url, config)).pipe(
map(response => response.data),
shareReplay(1)
)
}
post<T>(url: string, data: any, config?: RequestConfig): Observable<T> {
return from(this.tf.post<T>(url, { ...config, data })).pipe(
map(response => response.data)
)
}
put<T>(url: string, data: any, config?: RequestConfig): Observable<T> {
return from(this.tf.put<T>(url, { ...config, data })).pipe(
map(response => response.data)
)
}
patch<T>(url: string, data: any, config?: RequestConfig): Observable<T> {
return from(this.tf.patch<T>(url, { ...config, data })).pipe(
map(response => response.data)
)
}
delete<T>(url: string, config?: RequestConfig): Observable<T> {
return from(this.tf.delete<T>(url, config)).pipe(
map(response => response.data)
)
}
// Polling helper
poll<T>(
url: string,
intervalMs: number,
config?: RequestConfig
): Observable<T> {
return interval(intervalMs).pipe(
startWith(0),
switchMap(() => this.get<T>(url, config))
)
}
// Retry with backoff
getWithRetry<T>(
url: string,
maxRetries = 3,
config?: RequestConfig
): Observable<T> {
return this.get<T>(url, config).pipe(
retryWhen(errors =>
errors.pipe(
delay(1000),
take(maxRetries),
tap(err => console.log('Retrying...', err))
)
)
)
}
// Cache with refresh
getCached<T>(
url: string,
refreshInterval?: number,
config?: RequestConfig
): Observable<T> {
const initial$ = this.get<T>(url, config)
if (!refreshInterval) {
return initial$.pipe(shareReplay(1))
}
const refresh$ = interval(refreshInterval).pipe(
switchMap(() => this.get<T>(url, config))
)
return merge(initial$, refresh$).pipe(
shareReplay(1)
)
}
}
// State management service
@Injectable()
export class TypedFetchState<T> {
private readonly data$ = new BehaviorSubject<T | null>(null)
private readonly loading$ = new BehaviorSubject<boolean>(false)
private readonly error$ = new BehaviorSubject<Error | null>(null)
readonly data = this.data$.asObservable()
readonly loading = this.loading$.asObservable()
readonly error = this.error$.asObservable()
readonly state$ = this.data$.pipe(
map(data => ({
data,
loading: this.loading$.value,
error: this.error$.value
}))
)
constructor(
private fetcher: () => Observable<T>
) {}
load(): Observable<T> {
this.loading$.next(true)
this.error$.next(null)
return this.fetcher().pipe(
tap(data => {
this.data$.next(data)
this.loading$.next(false)
}),
catchError(error => {
this.error$.next(error)
this.loading$.next(false)
return throwError(() => error)
})
)
}
refresh(): Observable<T> {
return this.load()
}
update(data: T): void {
this.data$.next(data)
}
clear(): void {
this.data$.next(null)
this.error$.next(null)
this.loading$.next(false)
}
}
// Pagination service
@Injectable()
export class PaginationService<T> {
private readonly currentPage$ = new BehaviorSubject<number>(1)
private readonly pageSize$ = new BehaviorSubject<number>(10)
private readonly totalItems$ = new BehaviorSubject<number>(0)
private readonly items$ = new BehaviorSubject<T[]>([])
private readonly loading$ = new BehaviorSubject<boolean>(false)
readonly currentPage = this.currentPage$.asObservable()
readonly pageSize = this.pageSize$.asObservable()
readonly totalItems = this.totalItems$.asObservable()
readonly items = this.items$.asObservable()
readonly loading = this.loading$.asObservable()
readonly totalPages$ = this.totalItems$.pipe(
map(total => Math.ceil(total / this.pageSize$.value))
)
readonly hasPrev$ = this.currentPage$.pipe(
map(page => page > 1)
)
readonly hasNext$ = this.currentPage$.pipe(
switchMap(page =>
this.totalPages$.pipe(
map(total => page < total)
)
)
)
constructor(
private fetchFn: (page: number, pageSize: number) => Observable<{
items: T[]
total: number
}>
) {
// Auto-fetch on page/size change
merge(
this.currentPage$,
this.pageSize$
).pipe(
debounceTime(300),
tap(() => this.loading$.next(true)),
switchMap(() =>
this.fetchFn(
this.currentPage$.value,
this.pageSize$.value
)
)
).subscribe({
next: ({ items, total }) => {
this.items$.next(items)
this.totalItems$.next(total)
this.loading$.next(false)
},
error: (error) => {
console.error('Pagination error:', error)
this.loading$.next(false)
}
})
}
goToPage(page: number): void {
this.currentPage$.next(page)
}
nextPage(): void {
const current = this.currentPage$.value
this.totalPages$.pipe(take(1)).subscribe(total => {
if (current < total) {
this.currentPage$.next(current + 1)
}
})
}
prevPage(): void {
const current = this.currentPage$.value
if (current > 1) {
this.currentPage$.next(current - 1)
}
}
setPageSize(size: number): void {
this.pageSize$.next(size)
this.currentPage$.next(1) // Reset to first page
}
}
// Form service
@Injectable()
export class TypedFetchForm<T extends Record<string, any>> {
private readonly values$ = new BehaviorSubject<T>({} as T)
private readonly errors$ = new BehaviorSubject<Partial<Record<keyof T, string>>>({})
private readonly touched$ = new BehaviorSubject<Partial<Record<keyof T, boolean>>>({})
private readonly submitting$ = new BehaviorSubject<boolean>(false)
readonly values = this.values$.asObservable()
readonly errors = this.errors$.asObservable()
readonly touched = this.touched$.asObservable()
readonly submitting = this.submitting$.asObservable()
readonly isValid$ = this.errors$.pipe(
map(errors => Object.keys(errors).length === 0)
)
constructor(
private initialValues: T,
private validators?: Partial<Record<keyof T, (value: any) => string | null>>
) {
this.values$.next(initialValues)
}
setValue(field: keyof T, value: any): void {
const current = this.values$.value
this.values$.next({ ...current, [field]: value })
// Clear error on change
const errors = this.errors$.value
if (errors[field]) {
const { [field]: _, ...rest } = errors
this.errors$.next(rest)
}
// Mark as touched
const touched = this.touched$.value
this.touched$.next({ ...touched, [field]: true })
// Validate field
this.validateField(field)
}
setValues(values: Partial<T>): void {
const current = this.values$.value
this.values$.next({ ...current, ...values })
// Validate all changed fields
Object.keys(values).forEach(field => {
this.validateField(field as keyof T)
})
}
private validateField(field: keyof T): void {
if (!this.validators?.[field]) return
const value = this.values$.value[field]
const error = this.validators[field]!(value)
const errors = this.errors$.value
if (error) {
this.errors$.next({ ...errors, [field]: error })
} else {
const { [field]: _, ...rest } = errors
this.errors$.next(rest)
}
}
validate(): boolean {
if (!this.validators) return true
const values = this.values$.value
const errors: Partial<Record<keyof T, string>> = {}
Object.entries(this.validators).forEach(([field, validator]) => {
const error = validator!(values[field as keyof T])
if (error) {
errors[field as keyof T] = error
}
})
this.errors$.next(errors)
return Object.keys(errors).length === 0
}
async submit(
onSubmit: (values: T) => Observable<any>
): Promise<void> {
if (!this.validate()) return
this.submitting$.next(true)
try {
await onSubmit(this.values$.value).toPromise()
} finally {
this.submitting$.next(false)
}
}
reset(): void {
this.values$.next(this.initialValues)
this.errors$.next({})
this.touched$.next({})
this.submitting$.next(false)
}
}
// WebSocket service
@Injectable()
export class TypedFetchWebSocket<T> {
private socket$ = new Subject<WebSocket>()
private messages$ = new Subject<T>()
private connected$ = new BehaviorSubject<boolean>(false)
readonly messages = this.messages$.asObservable()
readonly connected = this.connected$.asObservable()
connect(url: string): void {
const ws = tf.websocket(url)
ws.onopen = () => {
this.connected$.next(true)
this.socket$.next(ws)
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as T
this.messages$.next(data)
} catch (error) {
console.error('WebSocket parse error:', error)
}
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
ws.onclose = () => {
this.connected$.next(false)
}
}
send(data: any): void {
this.socket$.pipe(take(1)).subscribe(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
}
})
}
disconnect(): void {
this.socket$.pipe(take(1)).subscribe(ws => {
ws.close()
})
}
}
Angular Component Examples
Using the services in Angular components:
// weather-display.component.ts
import { Component, Input, OnInit } from '@angular/core'
import { TypedFetchService } from 'typedfetch-angular'
import { Observable } from 'rxjs'
@Component({
selector: 'app-weather-display',
template: `
<div class="weather-display">
<ng-container *ngIf="weather$ | async as weather; else loading">
<div class="weather-card">
<h2>{{ weather.city }}</h2>
<div class="temperature">{{ weather.temperature }}°C</div>
<div class="condition">{{ weather.condition }}</div>
<button (click)="refresh()">Refresh</button>
</div>
</ng-container>
<ng-template #loading>
<app-loading-spinner></app-loading-spinner>
</ng-template>
</div>
`
})
export class WeatherDisplayComponent implements OnInit {
@Input() city!: string
weather$!: Observable<Weather>
constructor(private tf: TypedFetchService) {}
ngOnInit() {
this.loadWeather()
}
loadWeather() {
this.weather$ = this.tf.getCached<Weather>(
`/api/weather/${this.city}`,
60000 // Refresh every minute
)
}
refresh() {
this.loadWeather()
}
}
// weather-list.component.ts
@Component({
selector: 'app-weather-list',
template: `
<div class="weather-list">
<div class="controls">
<input
[(ngModel)]="searchTerm"
(ngModelChange)="search($event)"
placeholder="Search cities..."
/>
<select
[(ngModel)]="pageSize"
(ngModelChange)="pagination.setPageSize($event)"
>
<option [value]="10">10 per page</option>
<option [value]="25">25 per page</option>
<option [value]="50">50 per page</option>
</select>
</div>
<div *ngIf="pagination.loading | async" class="loading">
Loading...
</div>
<div class="cities">
<app-weather-card
*ngFor="let city of pagination.items | async"
[city]="city"
></app-weather-card>
</div>
<div class="pagination">
<button
(click)="pagination.prevPage()"
[disabled]="!(pagination.hasPrev$ | async)"
>
Previous
</button>
<span>
Page {{ pagination.currentPage | async }}
of {{ pagination.totalPages$ | async }}
</span>
<button
(click)="pagination.nextPage()"
[disabled]="!(pagination.hasNext$ | async)"
>
Next
</button>
</div>
</div>
`,
providers: [PaginationService]
})
export class WeatherListComponent {
searchTerm = ''
pageSize = 10
constructor(
public pagination: PaginationService<City>,
private tf: TypedFetchService
) {
// Initialize pagination with fetch function
this.pagination = new PaginationService<City>(
(page, pageSize) => {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString()
})
if (this.searchTerm) {
params.set('search', this.searchTerm)
}
return this.tf.get<{ items: City[], total: number }>(
`/api/cities?${params}`
)
}
)
}
search(term: string) {
this.pagination.goToPage(1) // Reset to first page
}
}
// weather-form.component.ts
@Component({
selector: 'app-weather-form',
template: `
<form (ngSubmit)="onSubmit()">
<div class="form-group">
<label>City Name</label>
<input
[value]="form.values | async | getValue: 'city'"
(input)="form.setValue('city', $event.target.value)"
(blur)="form.setValue('city', $event.target.value)"
[class.error]="hasError('city')"
/>
<span
*ngIf="hasError('city')"
class="error-message"
>
{{ getError('city') }}
</span>
</div>
<div class="form-group">
<label>Temperature Unit</label>
<select
[value]="form.values | async | getValue: 'unit'"
(change)="form.setValue('unit', $event.target.value)"
>
<option value="celsius">Celsius</option>
<option value="fahrenheit">Fahrenheit</option>
</select>
</div>
<button
type="submit"
[disabled]="(form.submitting | async) || !(form.isValid$ | async)"
>
{{ (form.submitting | async) ? 'Adding...' : 'Add City' }}
</button>
</form>
`,
providers: [TypedFetchForm]
})
export class WeatherFormComponent {
constructor(
public form: TypedFetchForm<{ city: string; unit: string }>,
private tf: TypedFetchService
) {
// Initialize form
this.form = new TypedFetchForm(
{ city: '', unit: 'celsius' },
{
city: (value) => {
if (!value) return 'City is required'
if (value.length < 2) return 'City must be at least 2 characters'
return null
}
}
)
}
async onSubmit() {
await this.form.submit(values =>
this.tf.post('/api/cities', values).pipe(
tap(result => {
console.log('City added:', result)
this.form.reset()
})
)
)
}
hasError(field: string): boolean {
const errors = this.form.errors.value
const touched = this.form.touched.value
return !!(errors[field] && touched[field])
}
getError(field: string): string {
return this.form.errors.value[field] || ''
}
}
// live-weather.component.ts
@Component({
selector: 'app-live-weather',
template: `
<div class="live-weather">
<h3>Live Updates for {{ city }}</h3>
<app-connection-status
[connected]="ws.connected | async"
></app-connection-status>
<div
*ngIf="latestUpdate$ | async as update"
class="update"
>
<span class="temp">{{ update.temperature }}°C</span>
<span class="time">{{ update.timestamp | date:'short' }}</span>
</div>
<button
(click)="requestUpdate()"
[disabled]="!(ws.connected | async)"
>
Request Update
</button>
<div class="history">
<h4>Recent Updates</h4>
<div
*ngFor="let update of recentUpdates$ | async"
class="update-item"
>
{{ update.temperature }}°C at
{{ update.timestamp | date:'short' }}
</div>
</div>
</div>
`,
providers: [TypedFetchWebSocket]
})
export class LiveWeatherComponent implements OnInit {
@Input() city!: string
latestUpdate$!: Observable<WeatherUpdate>
recentUpdates$!: Observable<WeatherUpdate[]>
constructor(public ws: TypedFetchWebSocket<WeatherUpdate>) {}
ngOnInit() {
this.ws.connect(`/ws/weather/${this.city}`)
// Track latest update
this.latestUpdate$ = this.ws.messages.pipe(
scan((acc, update) => update, null),
filter(update => update !== null)
)
// Keep last 10 updates
this.recentUpdates$ = this.ws.messages.pipe(
scan((acc, update) => [update, ...acc].slice(0, 10), [])
)
}
requestUpdate() {
this.ws.send({
type: 'REQUEST_UPDATE',
city: this.city
})
}
ngOnDestroy() {
this.ws.disconnect()
}
}
Framework-Agnostic Patterns
Some patterns work across all frameworks:
// Shared utilities
export class TypedFetchUtils {
// Debounced search
static createSearch<T>(
searchFn: (query: string) => Promise<T[]>,
debounceMs = 300
) {
let timeout: NodeJS.Timeout
let lastQuery = ''
return (query: string): Promise<T[]> => {
lastQuery = query
return new Promise((resolve) => {
clearTimeout(timeout)
timeout = setTimeout(async () => {
if (query === lastQuery) {
const results = await searchFn(query)
resolve(results)
}
}, debounceMs)
})
}
}
// Optimistic updates
static optimisticUpdate<T>(
getCurrentData: () => T,
updateFn: (data: T) => T,
persistFn: (data: T) => Promise<T>,
onError?: (error: Error, originalData: T) => void
) {
const originalData = getCurrentData()
const optimisticData = updateFn(originalData)
// Update UI immediately
// Framework will handle this differently
// Persist in background
persistFn(optimisticData).catch(error => {
// Revert on error
onError?.(error, originalData)
})
}
// Request deduplication
private static pendingRequests = new Map<string, Promise<any>>()
static dedupe<T>(
key: string,
requestFn: () => Promise<T>
): Promise<T> {
const pending = this.pendingRequests.get(key)
if (pending) return pending
const promise = requestFn().finally(() => {
this.pendingRequests.delete(key)
})
this.pendingRequests.set(key, promise)
return promise
}
}
// Framework detection and auto-configuration
export function configureTypedFetch() {
// Detect framework
const framework = detectFramework()
// Apply framework-specific optimizations
switch (framework) {
case 'react':
// React batches state updates
tf.configure({ batchRequests: true })
break
case 'vue':
// Vue has reactivity system
tf.configure({ cacheStrategy: 'memory' })
break
case 'angular':
// Angular has zone.js
tf.configure({ useZone: true })
break
case 'svelte':
// Svelte compiles away
tf.configure({ minimal: true })
break
}
}
function detectFramework(): string {
if (typeof window !== 'undefined') {
if (window.React || window.next) return 'react'
if (window.Vue) return 'vue'
if (window.ng) return 'angular'
if (window.__svelte) return 'svelte'
}
return 'unknown'
}
Best Practices for Framework Integration 🎯
1. Respect Framework Idioms
// React: Hooks
const { data } = useTypedFetch(url)
// Vue: Composition API
const { data } = useTypedFetch(url)
// Svelte: Stores
const { data } = createFetchStore(url)
// Angular: Observables
data$ = this.tf.get(url)
2. Handle Lifecycle Properly
// Always clean up
// React: useEffect cleanup
// Vue: onUnmounted
// Svelte: onDestroy
// Angular: ngOnDestroy
3. Optimize for Framework
// React: useMemo for expensive computations
// Vue: computed for derived state
// Svelte: $ for reactive declarations
// Angular: pipe transforms
4. Type Safety First
// Always provide types
useTypedFetch<Weather>('/api/weather')
Practice Time! 🏋️
Exercise 1: Build Custom Hook
Create a custom hook for your framework:
// Your code here:
// - Data fetching
// - Caching
// - Error handling
// - Loading states
Exercise 2: Create State Manager
Build a state management solution:
// Your code here:
// - Global state
// - Actions
// - Subscriptions
// - DevTools
Exercise 3: Framework Bridge
Create a bridge between frameworks:
// Your code here:
// - Share data
// - Sync state
// - Event bus
// - Type safety
Key Takeaways 🎯
- TypedFetch is framework-agnostic - Works everywhere
- Embrace framework idioms - Hooks, stores, observables
- Handle lifecycle properly - Cleanup is crucial
- Optimize for each framework - Different strengths
- Type safety throughout - Never lose types
- Share code wisely - Utils and patterns
- Test framework integration - Each has quirks
Common Pitfalls 🚨
- Fighting the framework - Go with the flow
- Memory leaks - Always cleanup
- Over-abstraction - Keep it simple
- Ignoring SSR - Plan for it
- Bundle size - Tree-shake properly
- Type erosion - Maintain types
What's Next?
You've integrated with every major framework! But what's the future of HTTP clients? In our final chapter, we'll explore:
- HTTP/3 and QUIC
- Edge computing
- AI-powered APIs
- The future of TypedFetch
Ready for the future? See you in Chapter 15! 🚀
Chapter Summary
- TypedFetch integrates beautifully with React through custom hooks
- Vue Composition API and TypedFetch are a perfect match
- Svelte stores provide reactive TypedFetch data
- Angular services wrap TypedFetch in observables
- Framework-specific optimizations improve performance
- Respect framework idioms while sharing core logic
- Always handle cleanup to prevent memory leaks
- Type safety is maintained across all integrations
Next Chapter Preview: The Future of HTTP - HTTP/3, edge computing, AI integration, and what's next for TypedFetch.