JSON in React: Fetch, State, Display, and TypeScript Types
Last updated:
React and JSON are inseparable: almost every React app fetches JSON from an API, stores it in state, and renders it to the DOM. This guide covers the complete workflow — fetching with useEffect, a reusable custom hook, POST requests, displaying data, TypeScript typing, global state management, and localStorage persistence — with production-ready patterns for each.
Fetching JSON with useEffect
The foundational pattern: three state variables (data, loading, error), a useEffect that fetches on mount or when a dependency changes, and conditional rendering based on state.
import { useState, useEffect } from 'react'
interface User { id: number; name: string; email: string }
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setLoading(true)
setError(null)
fetch(`https://api.example.com/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<User>
})
.then(setUser)
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [userId])
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
if (!user) return null
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}Key points: setLoading(true) and setError(null) at the top of the effect reset state on each new userId. The if (!res.ok) check is mandatory — fetch only rejects on network failure, not on HTTP 4xx/5xx errors. The dependency array [userId] re-runs the effect whenever the prop changes.
Custom useFetch Hook
Extract the fetch logic into a reusable hook. Add AbortController to cancel in-flight requests on unmount or URL change — this prevents the "setState on unmounted component" warning.
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const controller = new AbortController()
setLoading(true)
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json() as Promise<T>
})
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err)
})
.finally(() => setLoading(false))
return () => controller.abort() // cleanup on unmount or url change
}, [url])
return { data, loading, error }
}
// Usage
function ProductList() {
const { data: products, loading, error } = useFetch<Product[]>('/api/products')
if (loading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <ul>{products?.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}The AbortError check prevents spurious error state when a fetch is intentionally cancelled. The cleanup return from useEffect fires whenever url changes or the component unmounts. For production use, replace useFetch with TanStack Query or SWR, which add caching, deduplication, and background refetching on top of this same foundation.
Posting JSON (POST/PUT/PATCH)
Sending JSON requires three things: method: 'POST', a Content-Type: application/json header, and a JSON.stringify body. Always check response.ok before reading the response body.
async function createUser(userData: Omit<User, 'id'>): Promise<User> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Unknown error' }))
throw new Error(err.message ?? `HTTP ${res.status}`)
}
return res.json() as Promise<User>
}
function CreateUserForm() {
const [submitting, setSubmitting] = useState(false)
const [result, setResult] = useState<User | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSubmitting(true)
const form = new FormData(e.currentTarget)
try {
const user = await createUser({
name: form.get('name') as string,
email: form.get('email') as string,
})
setResult(user)
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button disabled={submitting}>{submitting ? 'Saving...' : 'Create'}</button>
{result && <p>Created user #{result.id}</p>}
</form>
)
}Displaying JSON Data
Two common display patterns: a debug viewer using <pre> with JSON.stringify, and a generic table renderer for JSON arrays.
// Debug display — raw JSON in <pre>
function JsonDebug({ data }: { data: unknown }) {
return (
<pre className="bg-gray-100 p-4 text-sm overflow-auto rounded">
{JSON.stringify(data, null, 2)}
</pre>
)
}
// Render JSON array as table
function DataTable<T extends Record<string, unknown>>({ rows }: { rows: T[] }) {
if (rows.length === 0) return <p>No data</p>
const headers = Object.keys(rows[0])
return (
<table>
<thead>
<tr>{headers.map(h => <th key={h}>{h}</th>)}</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{headers.map(h => <td key={h}>{String(row[h] ?? '')}</td>)}
</tr>
))}
</tbody>
</table>
)
}JSON.stringify(data, null, 2) produces human-readable indented output. The null second argument is a replacer — passing null includes all properties. DataTable extracts column headers from the first row's keys; it works for any flat JSON array without a schema.
TypeScript Typing for JSON APIs
TypeScript interfaces catch shape mismatches at compile time. Zod adds runtime validation — essential when you can't guarantee the API shape matches your interface.
// Define response types
interface PaginatedResponse<T> {
data: T[]
meta: { total: number; page: number; per_page: number }
}
// Type-safe fetch with Zod validation
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
created_at: z.string().datetime(),
})
type User = z.infer<typeof UserSchema>
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const raw = await res.json()
return UserSchema.parse(raw) // throws if shape is wrong
}z.infer<typeof UserSchema> derives the TypeScript type from the Zod schema — no duplication. schema.parse() throws a ZodError with a detailed message if the data doesn't match. For non-throwing validation use schema.safeParse(), which returns { success: true, data } or { success: false, error }.
State Management with JSON (Context and Zustand)
For JSON state shared across the component tree, React Context provides a built-in solution. Zustand is a simpler alternative with less boilerplate.
// Context-based JSON state
interface AppState {
user: User | null
setUser: (user: User | null) => void
}
const AppContext = React.createContext<AppState>({} as AppState)
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
return (
<AppContext.Provider value={{ user, setUser }}>
{children}
</AppContext.Provider>
)
}
// Zustand store (simpler alternative to Context + useReducer)
import { create } from 'zustand'
interface CartStore {
items: CartItem[]
addItem: (item: CartItem) => void
clear: () => void
}
const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clear: () => set({ items: [] }),
}))Zustand stores live outside the React tree, so components subscribe only to the slices they use — no unnecessary re-renders from unrelated state changes. The functional updater in addItem ensures immutable updates: [...state.items, item] creates a new array reference, which triggers a re-render only in components that read items.
JSON in localStorage / sessionStorage
Browser storage only holds strings. Use JSON.stringify to write and JSON.parse to read. Wrap both in try/catch — localStorage can throw in private browsing mode or when storage is full, and JSON.parse throws on malformed data.
// Custom hook for persisted JSON state
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? (JSON.parse(item) as T) : initialValue
} catch {
return initialValue
}
})
const setStoredValue = (newValue: T | ((val: T) => T)) => {
const valueToStore = newValue instanceof Function ? newValue(value) : newValue
setValue(valueToStore)
try {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (err) {
console.error('localStorage write failed:', err)
}
}
return [value, setStoredValue] as const
}
// Usage
const [prefs, setPrefs] = useLocalStorage('user-prefs', { theme: 'dark', lang: 'en' })The lazy initializer in useState(() => { ... }) reads from localStorage only once on mount. The functional updater support (newValue instanceof Function) mirrors the standard useState setter API. Use sessionStorage for data that should not persist across browser sessions — the API is identical.
FAQ
How do I fetch JSON and display it in React?
Use useEffect with fetch and useState. Declare three state variables: data (null initially), loading (true), and error (null). Inside useEffect, call fetch(url), check response.ok, call response.json(), and update state. Always handle loading and error in your render before trying to access data properties. TypeScript: type the response with response.json() as Promise<YourType>. Destructure only the fields you need. For production apps with caching, retry logic, and devtools, replace the manual pattern with React Query (TanStack Query), which handles all of this out of the box.
Why does React re-render infinitely when fetching JSON?
Infinite re-renders happen when the useEffect dependency array contains an unstable reference. The most common cause is initializing state with useState() or useState([]) — a new object or array reference is created on every render, so the effect sees it as changed. Fix it with useState(null) and guard with an early return. Another cause: passing an object or array literal as a prop directly into the dependency array. Either define it outside the component or memoize with useMemo. Missing the dependency array entirely causes the effect to run after every render. Provide [] for a one-time effect, or list stable primitive values.
How do I type JSON API responses in TypeScript with React?
Define a TypeScript interface that matches the API shape, then cast with response.json() as Promise<YourType>. This gives compile-time type checking — TypeScript will warn if you access a field that doesn't exist on the type. For runtime safety, use Zod: define a schema with z.object(), call schema.parse(await response.json()) — it throws a descriptive error if the actual data doesn't match the schema. z.infer<typeof Schema> derives the TypeScript type automatically, so there's no duplication. TypeScript generics only validate at compile time; Zod also validates at runtime — critical for third-party APIs you don't control.
How do I prevent stale closures when fetching JSON in useEffect?
Use AbortController: create one at the top of useEffect, pass its signal to fetch(), and return () => controller.abort() as the cleanup function. This cancels the in-flight request when the component unmounts or when the dependency (like a URL or ID) changes before the fetch completes. Filter out AbortError in the catch block so cancelled fetches don't set error state. React Query manages this automatically — every query gets an AbortController, making it the recommended approach for apps where correctness matters more than bundle size.
What is the best way to fetch JSON in React in 2026?
For production: TanStack Query (React Query) — provides caching, background refetching, request deduplication, retry on error, optimistic updates, pagination helpers, and devtools. For simple or one-off cases: useEffect + fetch + useState with AbortController is sufficient and adds zero dependencies. SWR (Vercel) is a lighter alternative to React Query with a similar stale-while-revalidate API. React 18.3+ use(promise) with Suspense is the future concurrent-mode pattern but is still stabilizing. In Next.js App Router, async Server Components fetch JSON on the server, eliminating client-side loading states entirely for initial page data.
How do I POST JSON in React with fetch?
Set method: 'POST', add headers: { "Content-Type": "application/json" }, and set body: JSON.stringify(yourData). Always check response.ok before reading the response body — a 4xx or 5xx response body may or may not be valid JSON, so wrap the error body parse in .catch(() => ()). Track a submitting boolean state to disable the submit button during the request. Note: HTML forms can submit as FormData (multipart or URL-encoded), but JSON APIs require an explicit JSON.stringify body with the Content-Type: application/json header — FormData will not be serialized as JSON automatically.
How do I display raw JSON in a React component?
Wrap in a <pre> tag: <pre>{JSON.stringify(data, null, 2)}</pre>. The null second argument includes all properties; 2 sets 2-space indentation. Add overflow-auto and a monospace font to handle large payloads gracefully. For an interactive collapsible tree view, use react-json-view (npm install react-json-view). Avoid rendering entire large JSON objects in production UI — select specific fields instead. A useful pattern: create a JsonDebug component that renders null in production (process.env.NODE_ENV === 'production') and the <pre> block in development.
How do I store JSON in React state?
useState accepts any JavaScript value including objects and arrays: useState<User | null>(null). Never mutate the state object directly — always create a new reference: setUser({ ...user, name: "new" }). For state with complex update logic, useReducer is more predictable and testable than multiple useState calls. Avoid putting the entire API response in a single state variable — keep data, loading, and error separate. For global JSON state, use React Context for small apps or Zustand for larger ones. For persistent JSON state across page loads, serialize to localStorage with JSON.stringify and deserialize on init with JSON.parse wrapped in try/catch.
Definitions
- useEffect
- React hook that runs side effects (data fetching, subscriptions) after render; dependency array controls when it re-runs; return a cleanup function to cancel subscriptions or abort fetches on unmount.
- AbortController
- Web API that cancels fetch requests; create one per useEffect, pass signal to fetch(), call abort() in the cleanup function to prevent setting state on unmounted components.
- useState
- React hook that declares local component state; returns current value and a setter; triggers re-render when setter is called with a different value.
- Zod
- TypeScript schema validation library; parse() validates at runtime and infers TypeScript types at compile time; safeParse() returns a result object instead of throwing.
- response.json()
- fetch API method that reads the response body and parses it as JSON; returns a Promise; must only be called once per response; always check response.ok first.
Further reading and primary sources
- React docs: Synchronizing with Effects — Official React guide to useEffect, dependency arrays, cleanup, and fetching data
- TanStack Query — Production-grade data fetching for React: caching, background refetch, pagination, and devtools
- Zod docs — TypeScript-first schema validation with runtime parsing and automatic type inference
- JSON Parse Safe TypeScript (Jsonic) — Safe JSON.parse patterns in TypeScript: try/catch, Zod, and custom parsers
- JSON Schema Patterns (Jsonic) — JSON Schema validation patterns: required fields, nested objects, and error messages