Skip to content

Shipping an AI Feature in React Native

12 min read · updated August 4, 2026

Most of a React Native AI feature is the React code you already know. Three things are genuinely different, and each of them is found late: streaming does not work the way it does on the web, the operating system suspends your request when the user switches apps, and app review has rules about AI-generated content that reject builds.

The streaming gap

On the web, response.body is a ReadableStream and you read it incrementally. React Native’s traditional fetch is a polyfill implemented over XMLHttpRequest, and in that implementation response.body is not a readable stream you can consume progressively. The practical symptom is that await response.text() works and returns the whole answer at once, and the code path you copied from a web tutorial either throws on response.body.getReader() or silently waits for the whole response.

This area is actively changing and is the most version-sensitive thing on the page. Expo has shipped a standards-based fetch with streaming support in recent SDKs, React Native’s own networking layer has been revised more than once, and community packages come and go. Before writing any of the workaround below, test whether streaming already works on your exact React Native and Expo versions — the test is five lines and it may save you the whole section.
// Run this once on a real device, on your actual versions, before
// choosing an approach. It answers the question definitively.
async function canStream() {
  const res = await fetch(YOUR_STREAMING_ENDPOINT, { method: "POST", body: BODY });
  console.log("body present:", !!res.body);
  console.log("getReader present:", typeof (res.body as any)?.getReader === "function");

  if (typeof (res.body as any)?.getReader === "function") {
    const reader = (res.body as any).getReader();
    const first = await reader.read();
    // If this logs long before the full answer would have finished, you have
    // real streaming and can use the web code unchanged.
    console.log("first chunk at", Date.now(), first.value?.length, "bytes");
  }
}

If it works, use the web implementation from streaming tokens into a React UI unchanged — the buffered-flush hook is the same code. If it does not, the section below is the fallback that works everywhere.

Streaming with XMLHttpRequest

XMLHttpRequest exposes responseText during transfer, in readyState 3. That gives you the bytes received so far, and the delta since the last event is your new content. It is old API and it works on every React Native version.

// stream-xhr.ts
export type StreamHandle = { abort: () => void };

export function streamCompletion(
  url: string,
  body: unknown,
  handlers: {
    onDelta: (text: string) => void;
    onDone: () => void;
    onError: (message: string) => void;
  },
  token?: string,
): StreamHandle {
  const xhr = new XMLHttpRequest();
  let consumed = 0;      // how much of responseText we have already parsed
  let buffer = "";       // a partial SSE event, carried between events

  xhr.open("POST", url);
  xhr.setRequestHeader("Content-Type", "application/json");
  if (token) xhr.setRequestHeader("Authorization", "Bearer " + token);

  xhr.onreadystatechange = () => {
    // 3 = LOADING: some of the body has arrived. 4 = DONE.
    if (xhr.readyState !== 3 && xhr.readyState !== 4) return;

    // Only the part we have not seen yet.
    const fresh = xhr.responseText.slice(consumed);
    consumed = xhr.responseText.length;
    buffer += fresh;

    const parts = buffer.split("\n\n");
    buffer = parts.pop() ?? "";           // keep the incomplete tail

    for (const part of parts) {
      const line = part.split("\n").find((l) => l.startsWith("data:"));
      if (!line) continue;
      const payload = line.slice(5).trim();
      if (payload === "[DONE]") continue;

      try {
        const chunk = JSON.parse(payload);
        const delta = chunk.choices?.[0]?.delta?.content;
        if (delta) handlers.onDelta(delta);
      } catch {
        // A malformed event is not worth ending the stream over.
      }
    }

    if (xhr.readyState === 4) {
      if (xhr.status >= 200 && xhr.status < 300) handlers.onDone();
      else handlers.onError("HTTP " + xhr.status);
    }
  };

  xhr.onerror = () => handlers.onError("network error");
  xhr.send(JSON.stringify(body));

  return { abort: () => xhr.abort() };
}

Two costs to be honest about. responseText accumulates the whole response in memory, so a very long answer is held twice — once in the XHR and once in your state. For chat-sized answers that is irrelevant; for a megabyte of output it is not. And this is a text API, so it is unsuitable for binary streams; audio needs a different transport entirely.

Render it with the same buffered-flush discipline as on the web. React Native’s bridge makes per-token state updates more expensive than in a browser, not less, so batching into animation frames matters more here rather than less.

What happens when the app backgrounds

On the web, a backgrounded tab keeps its connection. On a phone it does not. Both platforms suspend an application shortly after it leaves the foreground, and a suspended process is not executing — your in-flight request does not fail with an error, it simply stops making progress, and may be torn down.

EventDescription
User switches appsA short grace period, then suspension. The connection may be dropped. Both platforms offer a way to request a brief extension for finishing work, and the exact APIs and their time budgets differ by platform and version — check current documentation rather than assuming a number.
Screen locksSame as switching away. A user who locks their phone during a 30-second generation comes back to a dead stream.
Low memoryA backgrounded app can be killed outright with no notice and no callback. Anything only in component state is gone.
Network changesWi-Fi to cellular changes the interface and drops the connection. This happens constantly in normal use and is the single most common cause of a dropped stream on mobile.

The architectural consequence is the same one from a chat UI that survives 500 messages, and it is more important here than on the web: the server must own the answer. If the generation exists only in the app’s memory, every one of the events above loses it. If the server records the generation, the app reconnects and resumes.

import { AppState, type AppStateStatus } from "react-native";
import { useEffect, useRef } from "react";

export function useResumeOnForeground(
  generationId: string | null,
  received: () => number,
  resume: (id: string, from: number) => void,
) {
  const previous = useRef<AppStateStatus>(AppState.currentState);

  useEffect(() => {
    const sub = AppState.addEventListener("change", (next) => {
      const wasBackground = previous.current.match(/inactive|background/);
      previous.current = next;

      // Coming back to the foreground: resume from what we already have
      // rather than regenerating, which would cost a second full request.
      if (wasBackground && next === "active" && generationId) {
        resume(generationId, received());
      }
    });
    return () => sub.remove();
  }, [generationId, received, resume]);
}

Persist the partial answer to local storage as it arrives, not only to state. A low-memory kill gives you no callback, so anything not already written is lost — and a user who returns to find their answer gone assumes the app is broken rather than that the OS reclaimed it.

There is no server in an app

A compiled mobile binary is not a secret. Strings in the bundle are readable with standard tooling, environment variables inlined at build time are in the binary, and a device with a proxy certificate installed shows every request. So the rule from the web holds with no softening: the provider key lives on your server and the app calls your server.

What the app can hold is a user credential — a session token scoped to one user, revocable, limited by your own limiter and attached to a payer. Store it in the platform keychain rather than in async storage, which is not encrypted.

  • Never ship a provider key, even for a beta, even with a low quota. Keys extracted from mobile binaries are traded, and the quota you set is the budget you are donating.
  • Use the platform secure store for the session token. On both platforms this is hardware-backed and survives backup extraction in a way plain storage does not.
  • Assume traffic is observable. Certificate pinning raises the bar and does not change the design; the endpoint must be safe to call directly, because it will be.
  • Make tokens short-lived and refreshable. A long-lived token on a lost phone is a long-lived problem.

The store rules that block AI apps

Both stores have review criteria that apply specifically to user-generated and AI-generated content, and they are enforced. These are the recurring rejection reasons for AI applications; the exact wording and section numbers change, so treat this as a list of things to check in the current guidelines rather than as quotations.

  1. Content moderation must exist and be demonstrable. An app that can display arbitrary model output is treated as user-generated-content. Expect to need a filter, a report mechanism, a way to block, and a stated response time — see adding a safety layer.
  2. The age rating must reflect what the model can produce, not what you intend it to produce. An unfiltered general-purpose model is not a young-children rating, and mis-rating is a common rejection.
  3. Subscriptions must use the platform’s billing for digital content consumed in the app. Credits for model usage are digital content. There are exceptions for genuine external accounts and for some business models, and they are narrower than people assume — read the current rules before designing the pricing.
  4. Privacy disclosures must list what is sent to third parties. If prompts go to a model provider, that is data leaving your app and it belongs in the privacy declarations, including whether it is used for training.
  5. The AI must not be the only feature, in the sense that a thin wrapper around a public API with no additional functionality is a documented rejection reason on both stores.
  6. Give reviewers working credentials and a working demo. An app whose main feature needs a signed-in account with credit will be rejected if the reviewer cannot reach it. Include a test account with a balance, and say in the review notes exactly which screen exercises the AI feature.
Store guidelines change several times a year and differ between the two platforms. Read the current version of both before submitting rather than relying on any summary, including this one. The costly failure here is not rejection — it is a rejection that arrives a week before a launch date, which is why this belongs in the plan rather than in the release checklist.

One product decision worth making early, because it affects the build: if you want the feature to work offline or without a per-request cost,an on-device model is possible on modern phones under the same arithmetic as running a model in the browser — weights size against available memory, and a download the user agrees to. It changes the binary size, the review conversation and the privacy disclosures all at once, so it is not a swap you make late.