Topic 10.3
Feedback and Recovery
In one line
Give users ways to react to an answer: thumbs with a reason, regenerate, edit their question, report a problem. Each action has a precise effect on the message list and on the server, and feedback only helps if you store it carefully and read it honestly.
Think of it like this
A comment card at a restaurant, plus a waiter who will take the plate back. The card tells the owner what to fix. The waiter fixes this table's meal right now. They are two different things, and an AI feature needs both: feedback for you, recovery for the user.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Regenerate
- Ask the model for a new answer to the same question, replacing the last answer.
- Edit and resend
- Change an earlier user message and ask again; everything after it is discarded.
- aria-pressed
- An attribute for a toggle button that says whether it is currently on. Screen readers announce "pressed" or "not pressed".
- Upsert
- Insert if new, update if it exists. A repeated request leaves the same result.
- Idempotent
- Safe to repeat: sending it twice has the same effect as once.
- Reason code
- A short fixed label for why a user was unhappy, such as
wrongorout-of-date. - Selection bias
- Feedback comes from people who chose to give it, who are not a fair sample of all users.
- Report
- A flag that sends one answer to a human to review, usually for safety or abuse.
Step by step
01Put the actions next to the answer
Buttons belong on the answer they refer to, usually below it. Show them when the answer is finished (status is ready), not while text is still arriving: rating half an answer is meaningless, and the buttons would jump as the text grows.
02See what regenerate and edit do
The first example asks a question, regenerates once and then edits the question. The log shows the whole story. (server) request carries 1 messages each time: after a regenerate the old answer is not sent. The assistant message gets a different id, same assistant id after regenerate: false. After the edit the user message keeps its id and the message count stays at two.
Use sendMessage({ text, messageId }) for edit and regenerate({ messageId }) to regenerate a specific assistant message.
const { messages, sendMessage, regenerate } = useChat({ /* transport */ });
regenerate(); // new answer to the last question
sendMessage({ text: "New wording", messageId: id }); // edit the user message with this id
sendMessage({ text: "A follow-up" }); // a normal new question03Save a rating the careful way
The second example shows three messages. Message 1: a double click on "Good answer" sends one request; the second click is ignored while the first is saving. Message 2: "Bad answer" opens a reason list, and the choice is sent. Message 3: the server fails, and the page says so and stays unmarked.
On the server side the route validates the rating and the reason, because the browser is not trusted (Topic 1.5), and stores one row per message.
04Make toggles accessible
Thumbs are toggle buttons. Give each a text label ("Good answer", "Bad answer"; an icon alone has no name) and aria-pressed so that a screen reader says whether it is on. Topic 10.6 covers the rest of the keyboard and screen reader story.
05Aggregate without fooling yourself
The third example turns a list of events into counts. Run it and read the three outputs: five votes give a count by reason but "too few votes to read" for the rate, an empty list works, and lowering the minimum prints a rate.
Try it yourself
- 1
Regenerate a specific answer
In the first example, change
regenerate()toregenerate({ messageId: messages[1].id })and run.The output is the same here, because the answer is the last one. Passing a message id matters in a longer conversation: with four messages,
regenerate({ messageId })for the first answer sends only the first question and removes that answer and everything after it. - 2
Remove the double-click guard
In the second example delete the line
if (sending.current) return console.log(…);and run.The server now logs
saved m1twice, butstored ratingsstays at 1: because the server stores one row per message, the repeat did no harm. The guard saves a request; the upsert is what keeps the data correct even when the guard fails. - 3
Send a bad reason
In the second example change
send("down", r)tosend("down", "rude")and click through.The server answers
400for a reason it does not know, so the rating is not saved: the message staysnot ratedand the page shows the retry message. The console stops after them1lines, because the scripted visitor keeps waiting form2to be saved, and it never is. The server's check is what keeps garbage out of your data.
Code & diagrams
The example moves from step to step when the previous answer is finished (status is ready), not on a clock. The (server) lines show what each request carried: always one message, because regenerate and edit both cut the history back to the question.
Preview
Console
Nothing logged yet.Expected console output
(server) request carries 1 messages
[0] u:How long is delivery? | a:Take 5 to 7 days for delivery. (try 1, asked: How long is delivery?)
(server) request carries 1 messages
[1] u:How long is delivery? | a:Take 5 to 7 days for delivery. (try 2, asked: How long is delivery?)
same assistant id after regenerate: false | messages: 2
(server) request carries 1 messages
[2] u:How long is delivery to Pune? | a:Take 5 to 7 days for delivery. (try 3, asked: How long is delivery to Pune?)
user message kept its id: true | messages: 2The scripted visitor waits for what the page shows, not for a fixed time. The first line is the double click: the second click is ignored while the first save runs. The failing server is shown as (server) Error, and the page ends unmarked with a retry message.
Preview
Console
Nothing logged yet.Expected console output
m1: ignored a click while saving
(server) saved m1 {"rating":"up","reason":null} | stored ratings: 1
m1: saved: up
(server) saved m2 {"rating":"down","reason":"wrong"} | stored ratings: 2
m2: saved: down (wrong)
(server) Error: database is down
m3: not rated | shown: Could not save your feedback. Tap again to retry.Six events become five votes because m2 changed its mind. The empty list is a normal case, not an error. The third line lowers the minimum to show how easily a tiny sample produces a confident-looking percentage.
Preview
Console
Nothing logged yet.Expected console output
{ votes: 5, down: 3, byReason: { 'out-of-date': 1, wrong: 2 }, downRate: 'too few votes to read' }
{ votes: 0, down: 0, byReason: {}, downRate: 'too few votes to read' }
60%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
Mark it saved before the server answers, and never undo it
In the second example move setSaved({ rating, reason }); to just before the fetch line and delete the original setSaved line.
Myth vs fact
Myth
Regenerate just asks again with the same input and replaces the text.
Fact
It also creates a new message id and sends only the history before the question. State keyed to the old id is gone.
Myth
A thumbs-down rate tells you how good the assistant is.
Fact
Only people with a strong opinion vote, and a small sample is noise. Use it to find examples to read, not as a score.
Myth
Store the full question and answer with every rating, it is more useful.
Fact
It is also personal data you did not need. Store the ids and reasons, and let users opt in to share the text (Topic 9.7).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Tie feedback to the exact version that produced the answer: store the model name and the prompt version with the rating (Topic 5.7 shows how to attach metadata to a message). Without that you cannot tell whether a change made things better or worse.
- ▸
A thumbs-down is an excellent source of test cases. Put the anonymised questions that get "wrong" into your evaluation set so the same mistake is caught before release (evals are taught in
/llm-engineering). - ▸
Rate-limit the feedback route and require a signed-in session if ratings feed analytics. An open endpoint is a way to skew your own data.
Remember this
- 1
The problem this solves: Answers are sometimes wrong, and the user is the first to know. If the only way to react is to type a new question, they leave. If the thumbs button does nothing visible, they stop pressing it. And if you collect ratings but never read them carefully, you learn nothing. This topic covers the buttons, what each one does inside
useChat, and how to treat the data. - 2
Four actions, four jobs. *Rate* (thumbs up or down): tells you how it went. *Regenerate*: asks for a different answer to the same question. *Edit*: changes the question and asks again. *Report*: flags a harmful or broken answer for a human. Do not merge them; each has a different promise to the user.
- 3
Regenerate, exactly.
regenerate()inuseChattakes the last assistant message away and asks again from the same history. The first example shows what the server then sees: only the messages up to the user's question (one message), not the old answer. The new assistant message gets a new id, so any state you keyed by the old id (a thumbs choice, an expanded panel) must be dropped or it will point at nothing. Topic 3.4 explains retry and regenerate; here the point is the effect on ids. - 4
Edit, exactly.
sendMessage({ text, messageId })replaces the user message with that id and removes every message after it before asking again. In the example the edited message keeps its id (so a React key stays stable) and the old answer is gone. If your product wants to keep the old branch, you must copy the old messages first (Topic 4.6 on branching). Say clearly on screen that editing discards the answers below. - 5
Thumbs with a reason. A bare thumbs-down tells you it was bad, not why. A short list of reasons (wrong, out of date, unhelpful, unsafe, other) turns a vote into something you can act on. Ask for the reason after the click, on the same row, and make it skippable. Send the minimum: the message id, the rating, the reason. Do not send the question or the answer text unless the user chooses to share it (Topic 9.7).
- 6
Optimistic or confirmed? Marking a thumb as pressed before the server answers feels fast, but if the save fails you now show a lie. The second example marks it only after the server says yes, keeps the button usable on failure and shows "Could not save your feedback. Tap again to retry." It also ignores a click while a save is running, so a double click sends one request, and the server stores one rating per message (an upsert), so repeats are harmless.
- 7
Read feedback honestly. The third example counts votes the right way: one vote per message (the last wins, because people change their minds), counts by reason, and refuses to print a percentage under a minimum number of votes. Five votes with three thumbs-down is not "60% failure". Also remember that people with a complaint vote far more often than people who are happy, so a thumbs-down rate is a signal to investigate, not a quality score.
- 8
Close the loop. Feedback you never read is decoration. Decide who reads it, how often, and what changes because of it (a corrected help page, a new test case for your evals, a prompt change). Quality evaluation on the model side is taught in
/llm-engineering; here your job is to deliver clean data to it.
Explain it without notes
What exactly does regenerate() do to the message list, and what does the server receive?
How does editing an earlier message differ from asking a new question?
Why does the feedback button mark itself only after the server answers?
Why not show a thumbs-down percentage when you have five votes?
Practice
Add a "Report" button to the second example that sends { messageId, rating: "report" }. Which parts of the server check must change?
Write the code that makes the feedback buttons appear only when status === "ready" and only on the last assistant message.
Trade-offs
- ↔
Asking for a reason gives better data but adds a step, and fewer people will finish it. Make the reason optional and one tap.
- ↔
Regenerate is cheap for the user but costs another model call. Count it in your cost limits (Phase 13) and consider limiting repeats.
Done when you can
I can say what
regenerate()and an edit withmessageIddo to the message list.I can build thumbs buttons that are labelled, use
aria-pressed, and ignore double clicks.I can save feedback so that a failed save is shown, not hidden.
I can validate and store one rating per message on the server.
I can summarise votes without printing a percentage from a handful of them.