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
aithat 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
streamTextresult: a stream of partial objects. Needs anoutputoption. - elementStream
- On a
streamTextresult withOutput.array: a stream of finished array elements, one by one. - useObject
- A React hook from
@ai-sdk/reactthat sends a request and gives you the growing partial object. - createTextStreamResponse
- A function from
aithat turns a stream of text (made withtoTextStream({ stream: result.stream })) into aResponsethat 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.
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
Print undefined on purpose
In the third example, change the
Itembody 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
Act too early
In the third example, add a button
<button id="order">Place order</button>that is enabled wheneverobject?.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 fromonFinishwith anobject(the validated one). - 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,
elementStreamgives you finished cards that never change;partialOutputStreamgives you cards that fill in.
Code & diagrams
The parser knows JSON, not your schema. It happily returns a qty of "two", and it reads a half-typed 12 as 1.
Preview
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' }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
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 }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
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-1042The 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
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 ErrorBreak 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)}.
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, soitems[0].qtyis 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
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-10is not valid JSON, soJSON.parsethrows on every chunk but the last. - 2
Partial JSON parsing.
parsePartialJson(text)from theaipackage 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 }.stateissuccessful-parse(the text was complete),repaired-parse(it had to be closed),failed-parse(it could not be read) orundefined-input(you passedundefined). The first live example feeds it longer and longer prefixes. Notice that{becomes{}and a half-typed name becomes a shorter name. - 3
What the SDK gives you. With
streamText({ output: Output.object({ schema }) }),result.partialOutputStreamis an async stream of partial objects, one each time the text grows enough to change the object.await result.outputis still the final, validated object. WithOutput.array({ element }),result.elementStreamhands you each element once it is complete. For arrays the model writes{ "elements": [ … ] }, which the SDK wraps for you. UseelementStreamwhen you want finished items only, andpartialOutputStreamwhen you want to watch every field appear. - 4
**On the client:
useObject.** From@ai-sdk/react,useObject({ api, schema })returnsobject(the latest partial),submit(input)(a POST with your input as JSON),isLoading,error,stop()andclear(). Your route returnscreateTextStreamResponse({ stream: toTextStream({ stream: result.stream }) })(both fromai): 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.useObjectalso acceptsonFinish({ object, error })andonError. - 5
A partial object is not validated. Every field may be missing, because it has not arrived yet: the type of
objectis a deep partial of your schema. Worse, a field that has arrived can still be wrong:qtycan be the string"two"and it shows up inobjectanyway. Only the end of the stream is checked against the schema, and the result goes toonFinish. A number can also be unfinished:{"qty":1is read as1while the model is still writing12. - 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 wordundefined. 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") untilonFinishgives you a validated object. - 7
Failure looks different here. The hook's
erroris set when the request fails (a 500 givesInternal Server Error). It is not set when the final object is invalid; that error only arrives inonFinish, and the half-filledobjectstays 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 ononFinishwith an error, mark what is shown as incomplete and offer a retry.stop()keeps the partial too, but it calls neitheronFinishnoronError(measured with this SDK version), and the hook'serrorstays empty. So your own Stop handler must mark what is shown as stopped or incomplete. - 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
Why can't you JSON.parse each chunk of a streamed object, and what does parsePartialJson do instead?
What is the difference between partialOutputStream and elementStream?
Why can a field of a partial object be of the wrong type, and where do you find out?
How do you tell a user that a streamed object was cut off, given the hook's error is empty?
When is it a bad idea to stream an object?
Practice
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.
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.
- ↔
elementStreamis 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.parseand whatparsePartialJsonreturns.I can stream partial objects with
partialOutputStreamand finished elements withelementStream.I can build a screen with
useObjectthat shows placeholders and never printsundefined.I can say which failures set the hook's
errorand which only reachonFinish.I can mark a stopped or cut-off object as incomplete and keep actions disabled until the final object.