JSON in React Native: fetch, AsyncStorage, and TypeScript Types

Last updated:

React Native uses the same browser-compatible fetch() API for JSON requests —await (await fetch(url)).json() returns a parsed JavaScript object with no extra libraries required. Local JSON files are importable directly via Metro bundler:import data from './data.json' gives you a typed object at build time. For persistence, AsyncStorage.setItem(key, JSON.stringify(data)) and JSON.parse(await AsyncStorage.getItem(key)) store and retrieve JSON locally across app restarts. With TypeScript, define interfaces for your API responses and castdata as MyType after parsing for full IDE autocompletion. This guide covers 5 topics: fetching JSON from APIs with fetch(), local JSON imports via Metro, AsyncStorage JSON persistence, TypeScript typing for API responses, error handling with try/catch, and passing JSON data between navigation screens.

Fetch JSON from APIs with fetch()

Bottom line: React Native ships with a global fetch() that follows the WHATWG Fetch specification — the same API you use in a browser or Node.js 18+. Call await fetch(url), check res.ok to verify a 2xx status, then call await res.json() to parse the response body. No npm install is required.

Always check res.ok before calling res.json(). A 404 or 500 response may still return a JSON body (many APIs send JSON error objects), but it is not your expected success payload. res.ok is true only for HTTP statuses 200–299. For POST requests with a JSON body, set Content-Type: application/json in headers and pass JSON.stringify(payload) as the body. Use AbortController to implement request timeouts — the same pattern as in browsers.

// GET JSON — check res.ok before res.json()
const res = await fetch('https://api.example.com/users/1')
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
const user = await res.json()

// POST with JSON body
const res = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice', role: 'admin' }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const newUser = await res.json()

// With timeout using AbortController
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 5000)
try {
  const res = await fetch('https://api.example.com/data', {
    signal: controller.signal,
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  const data = await res.json()
} finally {
  clearTimeout(timer)
}

// In a React Native component with useEffect
import { useEffect, useState } from 'react'

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    let cancelled = false
    fetch(`https://api.example.com/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(data => { if (!cancelled) setUser(data) })
      .catch(err => { if (!cancelled) setError(err.message) })
    return () => { cancelled = true }
  }, [userId])

  return user ? <Text>{user.name}</Text> : <Text>{error ?? 'Loading...'}</Text>
}

Import Local JSON Files with Metro

Bottom line: Metro bundler — React Native's JavaScript bundler — supports JSON imports natively. Use import data from './data.json' at the top of any file and Metro reads, parses, and bundles the JSON object at build time. With TypeScript, you get automatic type inference from the JSON shape with no manual interface needed.

Static JSON imports are resolved at bundle time, meaning the data is baked into the JS bundle — there is no file system read at runtime. This is ideal for static configuration, translation strings (i18n), country lists, and seed data. For dynamic loading where the path is determined at runtime, use require('./data.json') inside the function body. JSON files placed in an assets/ directory can also be served as static assets via the Metro asset system, but for data access, direct imports are the simpler approach.

// Static import — TypeScript infers types automatically
import config from './config.json'
import translations from './i18n/en.json'

// config.apiUrl is typed as string (inferred from JSON shape)
console.log(config.apiUrl)

// Dynamic require() — use when path is not known at build time
function loadLocale(lang: string) {
  // Metro bundles all matching requires statically
  const t = require(`./i18n/${lang}.json`)
  return t
}

// Typed JSON import with an explicit interface
import rawCountries from './countries.json'

interface Country {
  code: string
  name: string
  dialCode: string
}

// Cast to override inferred literal types
const countries = rawCountries as Country[]

// Use in a FlatList for performance with large arrays
import { FlatList, Text } from 'react-native'

function CountryList() {
  return (
    <FlatList
      data={countries}
      keyExtractor={item => item.code}
      renderItem={({ item }) => <Text>{item.name}</Text>}
    />
  )
}

// metro.config.js — JSON is supported by default, no extra config needed
// If you need to add a custom transformer for other file types:
// module.exports = { transformer: { assetPlugins: ['...'] } }

Persist JSON with AsyncStorage

Bottom line: @react-native-async-storage/async-storage is the standard key-value store for persisting data across React Native app sessions. Because it only accepts string values, always serialize with JSON.stringify before writing and deserialize with JSON.parse after reading. Install it with npm install @react-native-async-storage/async-storage.

getItem returns null when the key does not exist — always handle that case before parsing. For storing multiple items in one call, multiSet accepts an array of [key, value] pairs and is more efficient than multiplesetItem calls. multiGet retrieves several keys in a single async round-trip. AsyncStorage is not encrypted by default — for sensitive data such as auth tokens, use a secure storage library like react-native-keychain instead.

import AsyncStorage from '@react-native-async-storage/async-storage'

// Save JSON — always JSON.stringify before setItem
async function saveProfile(profile: UserProfile): Promise<void> {
  await AsyncStorage.setItem('user_profile', JSON.stringify(profile))
}

// Load JSON — getItem returns null when key is missing
async function loadProfile(): Promise<UserProfile | null> {
  const raw = await AsyncStorage.getItem('user_profile')
  if (raw === null) return null
  return JSON.parse(raw) as UserProfile
}

// Delete a key
await AsyncStorage.removeItem('user_profile')

// Store multiple items in one call (more efficient than multiple setItem)
await AsyncStorage.multiSet([
  ['user_profile', JSON.stringify(profile)],
  ['app_settings', JSON.stringify(settings)],
  ['last_sync', JSON.stringify({ timestamp: Date.now() })],
])

// Retrieve multiple keys in one round-trip
const pairs = await AsyncStorage.multiGet(['user_profile', 'app_settings'])
// pairs: [['user_profile', '{"name":"Alice",...}'], ['app_settings', '{"theme":"dark"}']]
const [profileRaw, settingsRaw] = pairs.map(([, value]) => value)
const profile = profileRaw ? JSON.parse(profileRaw) as UserProfile : null
const settings = settingsRaw ? JSON.parse(settingsRaw) as AppSettings : null

// Pattern: cache API response with expiry
async function getCachedData<T>(key: string, ttlMs: number): Promise<T | null> {
  const raw = await AsyncStorage.getItem(key)
  if (!raw) return null
  const { data, cachedAt } = JSON.parse(raw) as { data: T; cachedAt: number }
  if (Date.now() - cachedAt > ttlMs) {
    await AsyncStorage.removeItem(key)
    return null
  }
  return data
}

async function setCachedData<T>(key: string, data: T): Promise<void> {
  await AsyncStorage.setItem(key, JSON.stringify({ data, cachedAt: Date.now() }))
}

TypeScript Types for API Responses

Bottom line: Define a TypeScript interface matching your API response shape, then cast after parsing: const data = await res.json() as MyType. This gives IDE autocompletion and compile-time checks. For runtime safety, combine with Zod to validate that the actual response matches the expected shape.

Type assertions with as are compile-time only — they do not validate at runtime. A changed or malformed API response silently passes TypeScript checks and can cause runtime crashes when your code accesses an undefined property. For production apps, use Zod's schema.safeParse(data) which returns a discriminated union — { success: true, data: T } or { success: false, error: ZodError } — without throwing. Optional chaining (data?.user?.name ?? 'Unknown') provides a lightweight safety net for deeply nested properties that may occasionally be absent.

// Define interfaces for API responses
interface User {
  id: number
  name: string
  email: string
  avatar?: string          // optional field
  role: 'admin' | 'user'  // literal union
}

interface ApiResponse<T> {
  data: T
  meta: {
    total: number
    page: number
    perPage: number
  }
}

// Type cast after fetch — compile-time only, no runtime check
const res = await fetch('https://api.example.com/users/1')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const user = await res.json() as User

// Safe access with optional chaining
console.log(user?.avatar ?? 'default-avatar.png')

// Zod for runtime validation (install: npm install zod)
import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  avatar: z.string().optional(),
  role: z.enum(['admin', 'user']),
})

type User = z.infer<typeof UserSchema>  // derive type from schema

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`https://api.example.com/users/${id}`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  const raw = await res.json()
  const result = UserSchema.safeParse(raw)
  if (!result.success) {
    throw new Error(`Invalid response: ${result.error.message}`)
  }
  return result.data  // fully typed and validated
}

// Generic fetch helper with type parameter
async function fetchJson<T>(url: string, schema: z.ZodType<T>): Promise<T> {
  const res = await fetch(url)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  const raw = await res.json()
  return schema.parse(raw)  // throws ZodError on invalid shape
}

Handle JSON Errors in React Native

Bottom line: JSON errors in React Native come from three sources: network errors (no connectivity, timeout, DNS failure), HTTP errors (4xx/5xx responses), and parse errors (res.json() throws a SyntaxError when the body is not valid JSON). Handle each distinctly with try/catch and status checks.

Network errors cause fetch() to reject its Promise — the catch block receives a TypeError with a message like "Network request failed". HTTP errors do not reject the Promise — you must check res.ok or res.statusmanually before calling res.json(). Parse errors occur when a server returns a 200 status with an HTML error page or plain text body instead of JSON — always checkres.headers.get('Content-Type')?.includes('application/json') for critical endpoints. For offline handling, use @react-native-community/netinfo to detect connectivity before making requests and fall back to cached data from AsyncStorage.

import NetInfo from '@react-native-community/netinfo'

// Distinguish network errors from HTTP errors from parse errors
async function fetchWithErrorHandling<T>(url: string): Promise<T> {
  // Check connectivity before attempting the request
  const netState = await NetInfo.fetch()
  if (!netState.isConnected) {
    throw new Error('No internet connection')
  }

  let res: Response
  try {
    res = await fetch(url)
  } catch (err) {
    // fetch() rejected — network failure, DNS error, timeout
    throw new Error(`Network error: ${(err as TypeError).message}`)
  }

  // HTTP error — server responded but with a non-2xx status
  if (!res.ok) {
    // Try to parse an error body the server may have sent
    const contentType = res.headers.get('Content-Type') ?? ''
    if (contentType.includes('application/json')) {
      const errBody = await res.json()
      throw new Error(errBody.message ?? `HTTP ${res.status}`)
    }
    throw new Error(`HTTP ${res.status} ${res.statusText}`)
  }

  // Parse error — server returned 200 but body is not JSON
  const contentType = res.headers.get('Content-Type') ?? ''
  if (!contentType.includes('application/json')) {
    const text = await res.text()
    throw new Error(`Expected JSON, got: ${text.slice(0, 100)}`)
  }

  try {
    return await res.json() as T
  } catch (err) {
    throw new Error(`JSON parse error: ${(err as SyntaxError).message}`)
  }
}

// In a component — map error types to user-friendly messages
function useApiData<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetchWithErrorHandling<T>(url)
      .then(setData)
      .catch(err => setError(err.message))
      .finally(() => setLoading(false))
  }, [url])

  return { data, error, loading }
}

Bottom line: React Navigation passes route params as a JavaScript object — any JSON-serializable value can be passed via navigation.navigate('Detail', { item: myObject }) and read in the destination with const { item } = route.params. Params must be JSON-serializable because React Navigation serializes them for deep linking and state persistence.

Avoid passing large arrays or deeply nested objects through params — this increases serialization overhead and couples navigation to data fetching. The recommended pattern is to pass an ID and fetch or look up the full object in the destination screen. Functions, class instances, Date objects, and circular references cannot be serialized — they will be silently dropped or cause errors during deep linking. For truly global JSON state shared across many screens, use React Context, Zustand, or Redux — these hold in-memory state without serialization constraints.

import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'

// Define param types for the whole navigator
type RootStackParamList = {
  Home: undefined
  Detail: { itemId: number }          // pass ID, not full object
  Profile: { user: { id: number; name: string } }  // small objects are fine
}

type HomeNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Home'>

// Source screen — navigate with params
function HomeScreen() {
  const navigation = useNavigation<HomeNavigationProp>()

  function openDetail(id: number) {
    navigation.navigate('Detail', { itemId: id })
  }

  return (
    <Button title="Open Detail" onPress={() => openDetail(42)} />
  )
}

// Destination screen — read params and fetch full data
type DetailRouteProp = RouteProp<RootStackParamList, 'Detail'>

function DetailScreen() {
  const route = useRoute<DetailRouteProp>()
  const { itemId } = route.params  // typed as number
  const { data, error, loading } = useApiData<Item>(
    `https://api.example.com/items/${itemId}`
  )
  if (loading) return <ActivityIndicator />
  if (error) return <Text>Error: {error}</Text>
  return <Text>{data?.name}</Text>
}

// Deep linking — React Navigation serializes params to URL query strings
// ?itemId=42 maps to { itemId: 42 } — numbers and strings work fine
// Dates must be passed as ISO strings: { date: new Date().toISOString() }

// Zustand for complex shared JSON state (no serialization limits)
import { create } from 'zustand'

interface AppStore {
  selectedItem: Item | null
  setSelectedItem: (item: Item | null) => void
}

const useStore = create<AppStore>(set => ({
  selectedItem: null,
  setSelectedItem: item => set({ selectedItem: item }),
}))

Validate your React Native API JSON

Paste a JSON response from your React Native app into Jsonic to validate the structure and spot unexpected nulls before they crash your app.

Open JSON Validator

FAQ

How do I fetch JSON from an API in React Native?

Use the built-in fetch() API — no extra install needed. Call const res = await fetch(url), check res.ok to confirm a 2xx status, then call const data = await res.json() to parse the response body. Wrap in try/catch to handle network failures. For POST requests, set method: 'POST', headers: { 'Content-Type': 'application/json' }, and body: JSON.stringify(payload).

How do I store JSON data locally in React Native?

Use @react-native-async-storage/async-storage. AsyncStorage only accepts strings, so serialize with JSON.stringify(data) before calling setItem and deserialize with JSON.parse(raw) after calling getItem. Always handle the null return from getItem — it means the key does not exist yet.

How do I import a JSON file in React Native?

Use a static import: import data from './data.json'. Metro bundler resolves JSON files natively and TypeScript automatically infers the type from the JSON shape — no manual interface needed. For dynamic paths, use const data = require('./data.json') inside the component body instead.

How do I add TypeScript types to JSON API responses in React Native?

Define an interface and cast after parsing: const data = await res.json() as MyType. For runtime safety, use Zod: const result = schema.safeParse(await res.json()). Type assertions are compile-time only and will not catch a malformed API response at runtime — combine both approaches in production apps.

Can I use axios instead of fetch in React Native?

Yes. Install with npm install axios. Axios automatically parses JSON responses — response.data is already the parsed object with no .json() call needed. Axios also throws on 4xx/5xx responses by default, unlike fetch which only rejects on network errors. The trade-off is bundle size (~14 KB gzipped); for most apps, the built-in fetch is sufficient.

How do I pass JSON data between screens in React Native?

Use React Navigation route params: navigation.navigate('Detail', { item: myObject }) and read with const { item } = route.params. Params must be JSON-serializable — no functions or class instances. For large datasets, pass only an ID and fetch the full data in the destination screen. For complex shared state, use React Context or Zustand.

Further reading and primary sources

  • React Native Networking docsOfficial React Native documentation for fetch(), WebSockets, and network security configuration on iOS and Android
  • AsyncStorage docsOfficial documentation for @react-native-async-storage/async-storage covering installation, API reference, and best practices
  • Metro bundler JSON configurationMetro bundler configuration reference including resolver options, transformer settings, and asset handling for JSON files
  • React Native TypeScript setupOfficial guide for using TypeScript with React Native, including tsconfig setup, component typing, and navigation types
  • Zod validationTypeScript-first schema validation library for validating JSON API responses at runtime with full type inference