Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~30 min· topic 4 of 7

Topic 7.4

Streaming Partial Objects

In one line

A long object (a list of items, a summary with sections) can be shown while the model is still writing it. parsePartialJson closes the open brackets of half-written JSON, the SDK gives you partial objects (partialOutputStream, useObject), and your screen must cope with fields that are missing, half-written or still of the wrong type.

Think of it like this

Watching a clerk fill in a form in front of you. First the order number appears, then the first item, then its quantity. You can start reading before the form is finished. But you would not hand the form to the courier until the clerk puts down the pen. A partial object is a form you may read but must not act on.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Partial object
An object that is still being written. Some fields are missing and some values are unfinished.
parsePartialJson
A function from ai that closes the open ends of half-written JSON and parses it. Returns { value, state }.
repaired-parse
A state of parsePartialJson: the text was incomplete, so it was closed before parsing.
partialOutputStream
On a streamText result: a stream of partial objects. Needs an output option.
elementStream
On a streamText result with Output.array: a stream of finished array elements, one by one.
useObject
A React hook from @ai-sdk/react that sends a request and gives you the growing partial object.
createTextStreamResponse
A function from ai that turns a stream of text (made with toTextStream({ stream: result.stream })) into a Response that your route returns.
DeepPartial
A type where every field, at every depth, may be missing.
Skeleton
A grey placeholder shape shown where content will appear.

Step by step

01Close the half-written JSON

The first live example gives parsePartialJson eight prefixes of one reply, from empty text to the complete object. Read the state column: the first is failed-parse (nothing to read), the next six are repaired-parse and only the last is successful-parse.

Then the edge cases. Plain words (Sure! Here is) are failed-parse. undefined is undefined-input. A number cut short reads as 1 while the model is writing 12. Emoji and Hindi text come through unharmed. And {"qty":"two parses fine: the parser does not know about your schema.

02Watch the SDK do it

The second example streams a reply word by word and prints every partial object from partialOutputStream. You can see the list grow: items is first [], then [{}], then an item with only a name, then a name and quantity, then a second item starting as {}.

Then Output.array with elementStream: the same two items, but each appears only when it is complete.

03Show it in React

The third example is a real screen. The server route is five lines: streamText with Output.object, returned with createTextStreamResponse and toTextStream. The client is useObject. Read the console, which prints what is on screen after every change: "Looking up the order…", then "Order …", then the id, then … × … for an item that has arrived with no fields, then the name, then the quantity.

The Item component never prints undefined. It shows … for a missing name or quantity. At the end the example loads again and presses Stop as soon as the order id arrives. It is triggered by the stream itself ("the id has arrived"), never by a timer, so it behaves the same on a slow machine. The partial stays on screen after Stop.

Show it in Reactdiagram
Rendering diagram…

04Six endings

The fourth example runs six requests one after another and prints what each one left on screen. Read the lines with on screen:

good ends cleanly. wrongType leaves "qty":"two" on screen, and the error only reached onFinish. The two cut-off cases (network break and output limit) leave a half-finished item on screen with a validation error in onFinish, and the hook's error is still none. Plain words leave nothing. And a server crash is the only case where the hook's error is set.

Try it yourself

  1. 1

    Print undefined on purpose

    In the third example, change the Item body to {item.name} × {item.qty} (no placeholders) and watch the page, not the console.

    For a moment the item shows an empty name and an empty quantity (React prints nothing for undefined). The layout jumps as the words arrive. Placeholders keep the shape stable.

  2. 2

    Act too early

    In the third example, add a button <button id="order">Place order</button> that is enabled whenever object?.items?.length > 0.

    It lights up while the basket is half-written, with a quantity still unknown. Then change the condition to !isLoading && object. After Stop it is enabled with an incomplete basket, which is still wrong: enable it only from onFinish with an object (the validated one).

  3. 3

    Use elementStream for a list of cards

    In the second example, change the delay to 40 ms and log the time order of the two loops mentally: partials arrive many times per item, elements once per item.

    If you render a card per item, elementStream gives you finished cards that never change; partialOutputStream gives you cards that fill in.

Code & diagrams

Half-written JSON, closed and parsed

The parser knows JSON, not your schema. It happily returns a qty of "two", and it reads a half-typed 12 as 1.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

failed-parse     undefined
repaired-parse   {}
repaired-parse   {"orderId":"ORD-10"}
repaired-parse   {"orderId":"ORD-1042","items":[]}
repaired-parse   {"orderId":"ORD-1042","items":[{"name":"Masa"}]}
repaired-parse   {"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2},{"name":"Gin"}]}
repaired-parse   {"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2},{"name":"Ginger biscuits","qty":1}]}
successful-parse {"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2},{"name":"Ginger biscuits","qty":1}]}
not JSON at all: { value: undefined, state: 'failed-parse' }
input undefined: { value: undefined, state: 'undefined-input' }
number cut short: { qty: 1 } (the model was writing 12)
emoji and Hindi: { name: 'Chai ☕ चाय 🍪' }
wrong type, still parses: { qty: 'two' }
partialOutputStream and elementStream

Each line is one partial object. The list is first empty, then holds an empty item, then an item that fills in field by field. The elements at the end appear only when finished.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

{}
{"orderId":"ORD-1042"}
{"orderId":"ORD-1042","items":[]}
{"orderId":"ORD-1042","items":[{}]}
{"orderId":"ORD-1042","items":[{"name":"Masala"}]}
{"orderId":"ORD-1042","items":[{"name":"Masala chai"}]}
{"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2}]}
{"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2},{}]}
{"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2},{"name":"Ginger"}]}
{"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2},{"name":"Ginger biscuits"}]}
{"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2},{"name":"Ginger biscuits","qty":1}]}
final: { orderId: 'ORD-1042', items: [ { name: 'Masala chai', qty: 2 }, { name: 'Ginger biscuits', qty: 1 } ] }
element: { name: 'Masala chai', qty: 2 }
element: { name: 'Ginger biscuits', qty: 1 }
A basket that fills in on screen (useObject)

Each line is the text on screen after a change. Nothing says undefined. The second load is stopped by code when the id arrives, and the half-filled basket stays on screen.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

idle | No basket yet.
loading | Looking up the order…
loading | Order …
loading | ORD-1042
loading | ORD-1042
loading | ORD-1042 … × …
loading | ORD-1042 Masala × …
loading | ORD-1042 Masala chai × …
loading | ORD-1042 Masala chai × 2
loading | ORD-1042 Masala chai × 2 … × …
loading | ORD-1042 Masala chai × 2 Ginger × …
loading | ORD-1042 Masala chai × 2 Ginger biscuits × …
loading | ORD-1042 Masala chai × 2 Ginger biscuits × 1
idle | ORD-1042 Masala chai × 2 Ginger biscuits × 1
loading | Looking up the order…
loading | Order …
loading | ORD-1042
idle | ORD-1042
Six endings of a streamed object

The hook's error stays empty for every case except the server crash. A wrong type or a cut-off only shows in onFinish, and the half-filled object stays on screen. Your UI has to mark it as incomplete itself.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

good -> onFinish object: {"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2}]} | error: none
good -> on screen: {"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":2}]} | hook error: none
wrongType -> onFinish object: undefined | error: AI_TypeValidationError: Type validation failed: Value: {"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":"two"}]}.
wrongType -> on screen: {"orderId":"ORD-1042","items":[{"name":"Masala chai","qty":"two"}]} | hook error: none
(server) the stream broke: connection reset
cutByNetwork -> onFinish object: undefined | error: AI_TypeValidationError: Type validation failed: Value: {"orderId":"ORD-1042","items":[{"name":"Masala"}]}.
cutByNetwork -> on screen: {"orderId":"ORD-1042","items":[{"name":"Masala"}]} | hook error: none
cutByLimit -> onFinish object: undefined | error: AI_TypeValidationError: Type validation failed: Value: {"orderId":"ORD-1042","items":[{"name":"Masala"}]}.
cutByLimit -> on screen: {"orderId":"ORD-1042","items":[{"name":"Masala"}]} | hook error: none
words -> onFinish object: undefined | error: AI_TypeValidationError: Type validation failed: Value: undefined.
words -> on screen: undefined | hook error: none
(server) Error: provider down
crash -> onError: Error: Internal Server Error
crash -> on screen: undefined | hook error: Error: Internal Server Error

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Call a method on a field that has not arrived

In the third example, change the body of Item to {item.name.toUpperCase()} × {item.qty.toFixed(0)}.

terminal
$ Console
── what you'll see ──
idle | No basket yet.
loading | Looking up the order…
loading | Order …
loading | ORD-1042
loading | ORD-1042
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
An error occurred in the <Item> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.
 
Uncaught TypeError: Cannot read properties of null (reading 'click')

Myth vs fact

Myth

useObject only gives me objects that match my schema.

Fact

The object it returns while streaming is a partial parse and is not validated. A wrong type can be on screen. Only onFinish tells you whether the final object matched.

Myth

If the hook's error is empty, the answer is complete.

Fact

A cut-off or invalid final object leaves error empty. Check onFinish's error, and remember that stop() also leaves a partial and fires no callback.

Myth

Streaming objects is always a better experience.

Fact

For short objects it only flickers. It pays off for long lists and summaries.

Myth

Partial numbers are reliable.

Fact

A number can be unfinished: 1 while the model is writing 12. Show numbers only when the field after it has started or the object is finished.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Every chunk re-renders the component that reads object. For long lists, keep each row in its own memoised component (Topic 6.5) so only the growing last row re-renders.

  • ▸

    Use the next field as the signal that a field is complete. When items[1] exists, items[0] is finished, so items[0].qty is safe to show as a final number. That is how to avoid showing a half-typed quantity.

  • ▸

    On the server the stream can break after the client has already received most of the text. The server sees it in onError; the client sees only text that stops. Log it on the server with a request id so a support ticket ("my basket was cut off") can be matched to it.

  • ▸

    Because submit(input) sends JSON, validate that input in the route like any request body (Topic 0.2). A hook that talks to your own route does not make the route trusted.

Remember this

  1. 1

    The problem this solves: Topic 7.3 returns the object only when the whole reply is complete. For a short card that is fine. For a basket of ten items or a long summary, the user stares at a skeleton for many seconds. The text of a stream can be shown early (Phase 2). JSON cannot: {"orderId":"ORD-10 is not valid JSON, so JSON.parse throws on every chunk but the last.

  2. 2

    Partial JSON parsing. parsePartialJson(text) from the ai package repairs the unfinished end of the text: it closes the open string, the open object and the open list, then parses. It returns { value, state }. state is successful-parse (the text was complete), repaired-parse (it had to be closed), failed-parse (it could not be read) or undefined-input (you passed undefined). The first live example feeds it longer and longer prefixes. Notice that { becomes {} and a half-typed name becomes a shorter name.

  3. 3

    What the SDK gives you. With streamText({ output: Output.object({ schema }) }), result.partialOutputStream is an async stream of partial objects, one each time the text grows enough to change the object. await result.output is still the final, validated object. With Output.array({ element }), result.elementStream hands you each element once it is complete. For arrays the model writes { "elements": [ … ] }, which the SDK wraps for you. Use elementStream when you want finished items only, and partialOutputStream when you want to watch every field appear.

  4. 4

    **On the client: useObject.** From @ai-sdk/react, useObject({ api, schema }) returns object (the latest partial), submit(input) (a POST with your input as JSON), isLoading, error, stop() and clear(). Your route returns createTextStreamResponse({ stream: toTextStream({ stream: result.stream }) }) (both from ai): a plain text stream of the JSON as it is written. (result.toTextStreamResponse() still works in AI SDK 7 but is marked deprecated in favour of these two helpers.) The hook runs the same partial parser on what arrives. useObject also accepts onFinish({ object, error }) and onError.

  5. 5

    A partial object is not validated. Every field may be missing, because it has not arrived yet: the type of object is a deep partial of your schema. Worse, a field that has arrived can still be wrong: qty can be the string "two" and it shows up in object anyway. Only the end of the stream is checked against the schema, and the result goes to onFinish. A number can also be unfinished: {"qty":1 is read as 1 while the model is still writing 12.

  6. 6

    Render half-filled objects safely. Read every field with optional chaining (object?.items ?? [], item?.name). Show a placeholder (…, a grey bar) for a missing field instead of the word undefined. Do not call methods on fields (item.name.toUpperCase() crashes at the first {}). Reserve space so the page does not jump. Use list index as the key: the list only grows at the end. And do not enable the action that uses the data ("Place order", "Save") until onFinish gives you a validated object.

  7. 7

    Failure looks different here. The hook's error is set when the request fails (a 500 gives Internal Server Error). It is not set when the final object is invalid; that error only arrives in onFinish, and the half-filled object stays on screen. The same happens when the stream is cut off by a network error or by the output limit: the client just sees text that stops, the final check fails, and the partial stays. So on onFinish with an error, mark what is shown as incomplete and offer a retry. stop() keeps the partial too, but it calls neither onFinish nor onError (measured with this SDK version), and the hook's error stays empty. So your own Stop handler must mark what is shown as stopped or incomplete.

  8. 8

    When not to stream. An object with three short fields finishes in under a second, and streaming it only makes the card flicker. Stream when the object is long: lists, summaries with sections, extracted tables.

Explain it without notes

01

Why can't you JSON.parse each chunk of a streamed object, and what does parsePartialJson do instead?

02

What is the difference between partialOutputStream and elementStream?

03

Why can a field of a partial object be of the wrong type, and where do you find out?

04

How do you tell a user that a streamed object was cut off, given the hook's error is empty?

05

When is it a bad idea to stream an object?

Practice

01

Write a function isFinished(item) for the basket item that returns true only when name is a non-empty string and qty is a number, and use it to show a small "…" badge next to unfinished items.

02

Extend the onFinish of the third example so that when its error is set, the page shows "This basket may be incomplete. Try again." with a Retry button that calls submit again.

Trade-offs

  • ↔

    Streaming a partial object makes long answers feel fast, at the cost of a UI that must handle every field being absent or unfinished, and of re-rendering on every chunk.

  • ↔

    elementStream is simpler to render (only finished items) but shows nothing within an item until it is whole, so one very large item still looks slow.

Done when you can

  • I can explain why half-written JSON fails JSON.parse and what parsePartialJson returns.

  • I can stream partial objects with partialOutputStream and finished elements with elementStream.

  • I can build a screen with useObject that shows placeholders and never prints undefined.

  • I can say which failures set the hook's error and which only reach onFinish.

  • I can mark a stopped or cut-off object as incomplete and keep actions disabled until the final object.