# 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: ```typescript // 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( url: string | (() => string | null), options?: RequestConfig & { skip?: boolean refetchInterval?: number refetchOnWindowFocus?: boolean refetchOnReconnect?: boolean } ) { const [data, setData] = useState() const [error, setError] = useState() const [loading, setLoading] = useState(false) const [isValidating, setIsValidating] = useState(false) const abortControllerRef = useRef() 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( mutationFn: (variables: TVariables) => Promise<{ data: TData }> ) { const [data, setData] = useState() const [error, setError] = useState() 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( getUrl: (pageParam: number) => string, options?: RequestConfig & { initialPageParam?: number getNextPageParam?: (lastPage: T, allPages: T[]) => number | undefined } ) { const [pages, setPages] = useState([]) const [error, setError] = useState() 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(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( url: string, initialData?: T, options?: RequestConfig ) { const [data, setData] = useState(initialData) const [error, setError] = useState() 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(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( url: string, options?: { onMessage?: (data: T) => void onError?: (error: Error) => void onClose?: () => void } ) { const [data, setData] = useState() const [error, setError] = useState() const [connected, setConnected] = useState(false) const streamRef = useRef() useEffect(() => { const stream = tf.stream(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: ```typescript // 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( `/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 if (error) return return (

{data.city}

{data.temperature}°C
{data.condition}
{isValidating && }
) } 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 ( ) } function WeatherFeed() { const { data: pages, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteTypedFetch( (pageParam) => `/api/weather/feed?page=${pageParam}`, { getNextPageParam: (lastPage) => lastPage.nextPage } ) return (
{pages.map((page, i) => ( {page.items.map(weather => ( ))} ))} {hasNextPage && ( )}
) } // Real-time weather updates function LiveWeather({ city }: { city: string }) { const { data, connected, error } = useTypedFetchSubscription( `/api/weather/${city}/live`, { onMessage: (update) => { console.log('Weather update:', update) } } ) return (
{error && } {data && (
{data.temperature}°C {data.timestamp}
)}
) } ``` ## Vue.js Integration: Composition API Magic Vue developers love the Composition API. Let's create composables: ```typescript // 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( url: string | Ref | (() => string | null), options?: RequestConfig & { immediate?: boolean watch?: boolean refetchOnWindowFocus?: boolean transform?: (data: any) => T } ) { const data = ref() const error = ref() 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( mutationFn: (variables: TVariables) => Promise<{ data: TData }> ) { const data = ref() const error = ref() 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) => { instance.value = tf.create({ ...instance.value.defaults, ...newConfig }) } return { instance: computed(() => instance.value), updateConfig } } // Pagination composable export function usePagination( 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>( initialValues: T, options?: { onSubmit?: (values: T) => Promise validate?: (values: T) => Record } ) { const values = ref({ ...initialValues }) const errors = ref>({}) const touched = ref>({}) 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( url: string | Ref, options?: { immediate?: boolean onMessage?: (event: T) => void onError?: (error: Error) => void } ) { const data = ref() const error = ref() 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: ```vue ``` ## Svelte Integration: Stores and Actions Svelte's reactive stores are perfect for TypedFetch: ```typescript // 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( url: string | (() => string | null), options?: RequestConfig & { refetchInterval?: number refetchOnFocus?: boolean } ) { const data = writable(undefined) const error = writable(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(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, error: { subscribe: error.subscribe } as Readable, loading: { subscribe: loading.subscribe } as Readable, refetch: execute } } // Mutation store export function createMutationStore( mutationFn: (variables: TVariables) => Promise<{ data: TData }> ) { const data = writable(undefined) const error = writable(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, error: { subscribe: error.subscribe } as Readable, loading: { subscribe: loading.subscribe } as Readable, mutate } } // Derived stores for transformations export function createDerivedFetchStore( url: string | (() => string | null), transform: (data: T) => U, options?: RequestConfig ) { const store = createFetchStore(url, options) const derivedData = derived( store.data, $data => $data ? transform($data) : undefined ) return { ...store, data: derivedData } } // Pagination store export function createPaginationStore( 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, 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( url: string, options?: { protocols?: string | string[] reconnect?: boolean reconnectInterval?: number } ) { const messages = writable([]) const connected = writable(false) const error = writable(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, connected: { subscribe: connected.subscribe } as Readable, error: { subscribe: error.subscribe } as Readable, 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: ```svelte
{#if $loading} {:else if $error} {:else if $data}

{$data.city}

{$data.temperature}°C
{$data.condition}
{/if}
{#if $loading} {/if} {#if $error} {/if}
{#each $items as city (city.id)} {/each}

Live Updates for {city}

{#if $error} {/if} {#if latestUpdate}
{latestUpdate.temperature}°C {new Date(latestUpdate.timestamp).toLocaleTimeString()}
{/if}

Recent Updates

{#each $messages.slice(-10).reverse() as update}
{update.temperature}°C at {new Date(update.timestamp).toLocaleTimeString()}
{/each}
{#if $error}
Error: {$error.message}
{/if}
``` ## Angular Integration: Services and Observables Angular loves RxJS, so let's embrace it: ```typescript // 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(url: string, config?: RequestConfig): Observable { return from(this.tf.get(url, config)).pipe( map(response => response.data), shareReplay(1) ) } post(url: string, data: any, config?: RequestConfig): Observable { return from(this.tf.post(url, { ...config, data })).pipe( map(response => response.data) ) } put(url: string, data: any, config?: RequestConfig): Observable { return from(this.tf.put(url, { ...config, data })).pipe( map(response => response.data) ) } patch(url: string, data: any, config?: RequestConfig): Observable { return from(this.tf.patch(url, { ...config, data })).pipe( map(response => response.data) ) } delete(url: string, config?: RequestConfig): Observable { return from(this.tf.delete(url, config)).pipe( map(response => response.data) ) } // Polling helper poll( url: string, intervalMs: number, config?: RequestConfig ): Observable { return interval(intervalMs).pipe( startWith(0), switchMap(() => this.get(url, config)) ) } // Retry with backoff getWithRetry( url: string, maxRetries = 3, config?: RequestConfig ): Observable { return this.get(url, config).pipe( retryWhen(errors => errors.pipe( delay(1000), take(maxRetries), tap(err => console.log('Retrying...', err)) ) ) ) } // Cache with refresh getCached( url: string, refreshInterval?: number, config?: RequestConfig ): Observable { const initial$ = this.get(url, config) if (!refreshInterval) { return initial$.pipe(shareReplay(1)) } const refresh$ = interval(refreshInterval).pipe( switchMap(() => this.get(url, config)) ) return merge(initial$, refresh$).pipe( shareReplay(1) ) } } // State management service @Injectable() export class TypedFetchState { private readonly data$ = new BehaviorSubject(null) private readonly loading$ = new BehaviorSubject(false) private readonly error$ = new BehaviorSubject(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 ) {} load(): Observable { 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 { 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 { private readonly currentPage$ = new BehaviorSubject(1) private readonly pageSize$ = new BehaviorSubject(10) private readonly totalItems$ = new BehaviorSubject(0) private readonly items$ = new BehaviorSubject([]) private readonly loading$ = new BehaviorSubject(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> { private readonly values$ = new BehaviorSubject({} as T) private readonly errors$ = new BehaviorSubject>>({}) private readonly touched$ = new BehaviorSubject>>({}) private readonly submitting$ = new BehaviorSubject(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 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): 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> = {} 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 ): Promise { 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 { private socket$ = new Subject() private messages$ = new Subject() private connected$ = new BehaviorSubject(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: ```typescript // 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: `

{{ weather.city }}

{{ weather.temperature }}°C
{{ weather.condition }}
` }) export class WeatherDisplayComponent implements OnInit { @Input() city!: string weather$!: Observable constructor(private tf: TypedFetchService) {} ngOnInit() { this.loadWeather() } loadWeather() { this.weather$ = this.tf.getCached( `/api/weather/${this.city}`, 60000 // Refresh every minute ) } refresh() { this.loadWeather() } } // weather-list.component.ts @Component({ selector: 'app-weather-list', template: `
Loading...
`, providers: [PaginationService] }) export class WeatherListComponent { searchTerm = '' pageSize = 10 constructor( public pagination: PaginationService, private tf: TypedFetchService ) { // Initialize pagination with fetch function this.pagination = new PaginationService( (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: `
{{ getError('city') }}
`, 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: `

Live Updates for {{ city }}

{{ update.temperature }}°C {{ update.timestamp | date:'short' }}

Recent Updates

{{ update.temperature }}°C at {{ update.timestamp | date:'short' }}
`, providers: [TypedFetchWebSocket] }) export class LiveWeatherComponent implements OnInit { @Input() city!: string latestUpdate$!: Observable recentUpdates$!: Observable constructor(public ws: TypedFetchWebSocket) {} 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: ```typescript // Shared utilities export class TypedFetchUtils { // Debounced search static createSearch( searchFn: (query: string) => Promise, debounceMs = 300 ) { let timeout: NodeJS.Timeout let lastQuery = '' return (query: string): Promise => { 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( getCurrentData: () => T, updateFn: (data: T) => T, persistFn: (data: T) => Promise, 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>() static dedupe( key: string, requestFn: () => Promise ): Promise { 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 ```typescript // 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 ```typescript // Always clean up // React: useEffect cleanup // Vue: onUnmounted // Svelte: onDestroy // Angular: ngOnDestroy ``` ### 3. Optimize for Framework ```typescript // React: useMemo for expensive computations // Vue: computed for derived state // Svelte: $ for reactive declarations // Angular: pipe transforms ``` ### 4. Type Safety First ```typescript // Always provide types useTypedFetch('/api/weather') ``` ## Practice Time! 🏋️ ### Exercise 1: Build Custom Hook Create a custom hook for your framework: ```typescript // Your code here: // - Data fetching // - Caching // - Error handling // - Loading states ``` ### Exercise 2: Create State Manager Build a state management solution: ```typescript // Your code here: // - Global state // - Actions // - Subscriptions // - DevTools ``` ### Exercise 3: Framework Bridge Create a bridge between frameworks: ```typescript // Your code here: // - Share data // - Sync state // - Event bus // - Type safety ``` ## Key Takeaways 🎯 1. **TypedFetch is framework-agnostic** - Works everywhere 2. **Embrace framework idioms** - Hooks, stores, observables 3. **Handle lifecycle properly** - Cleanup is crucial 4. **Optimize for each framework** - Different strengths 5. **Type safety throughout** - Never lose types 6. **Share code wisely** - Utils and patterns 7. **Test framework integration** - Each has quirks ## Common Pitfalls 🚨 1. **Fighting the framework** - Go with the flow 2. **Memory leaks** - Always cleanup 3. **Over-abstraction** - Keep it simple 4. **Ignoring SSR** - Plan for it 5. **Bundle size** - Tree-shake properly 6. **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.