TypeFetched/manual/chapter-14-framework-integration.md
Casey Collier b85b9a63e2 Initial commit: TypedFetch - Zero-dependency, type-safe HTTP client
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
2025-07-20 12:35:43 -04:00

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 🎯

  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.