React chat message history: State management guide
Message history is the backbone of a functioning chatbot. Without it, each AI message would exist in isolation; with it, the conversation builds context and continuity. In React, managing message history means maintaining an array of message objects (user and assistant), handling updates efficiently, and persisting history to localStorage or a backend database.
This article covers state management patterns for chat conversations: how to structure message objects, update them as streams arrive, handle undo/redo, and prepare history for API calls that require the full conversation context. By the end, you'll have a bulletproof message storage layer that scales from a single browser tab to a full-featured app with chat persistence.
Structuring Message Objects
A message object minimally contains a role (user or assistant), content (the text), and metadata like timestamps. Here's a practical structure:
// Single message object
const message = {
id: 'msg_1718916000123', // Unique ID for undo/redo
role: 'user', // 'user' | 'assistant'
content: 'What is React?',
timestamp: Date.now(),
status: 'complete', // 'pending' | 'complete' | 'error'
error: null, // Error message if status === 'error'
tokens: 42, // Token count (for cost tracking)
};
// Message array in state
const [messages, setMessages] = useState([
{ id: 'msg_1', role: 'assistant', content: 'Hello! How can I help?', timestamp: 0, status: 'complete' },
{ id: 'msg_2', role: 'user', content: 'Tell me about React', timestamp: 100, status: 'complete' },
{ id: 'msg_3', role: 'assistant', content: 'React is a...', timestamp: 200, status: 'streaming' },
]);
The id field is crucial for identifying messages in lists and enabling undo/redo. The status field tracks whether a message is streaming, complete, or failed. This structure is compatible with both OpenAI and Anthropic API message formats.
Managing Message State with useState
For simple chat apps, useState is sufficient. Here's a hook that abstracts message management:
import { useState, useCallback } from 'react';
function useMessages(initialMessages = []) {
const [messages, setMessages] = useState(initialMessages);
const [history, setHistory] = useState([]); // Undo/redo stack
// Add a new user message
const addUserMessage = useCallback((content) => {
const newMessage = {
id: `msg_${Date.now()}`,
role: 'user',
content,
timestamp: Date.now(),
status: 'complete',
};
setMessages((prev) => [...prev, newMessage]);
return newMessage.id;
}, []);
// Add a pending assistant message (before streaming)
const addAssistantMessage = useCallback(() => {
const newMessage = {
id: `msg_${Date.now()}`,
role: 'assistant',
content: '',
timestamp: Date.now(),
status: 'streaming',
};
setMessages((prev) => [...prev, newMessage]);
return newMessage.id;
}, []);
// Update a message (e.g., append to its content while streaming)
const updateMessage = useCallback((messageId, updates) => {
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, ...updates } : msg
)
);
}, []);
// Delete a message
const deleteMessage = useCallback((messageId) => {
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
}, []);
// Clear all messages
const clearMessages = useCallback(() => {
setHistory((prev) => [...prev, messages]); // Save for undo
setMessages([]);
}, [messages]);
// Undo: restore previous message state
const undo = useCallback(() => {
if (history.length > 0) {
setHistory((prev) => {
const newHistory = [...prev];
const previousMessages = newHistory.pop();
return newHistory;
});
// Restore from history (simplified; in production, use a proper stack)
}
}, [history]);
return {
messages,
addUserMessage,
addAssistantMessage,
updateMessage,
deleteMessage,
clearMessages,
undo,
};
}
export default useMessages;
This hook provides all the mutation functions you need. Use it like:
function ChatApp() {
const { messages, addUserMessage, addAssistantMessage, updateMessage } = useMessages();
const handleSendMessage = async (userText) => {
addUserMessage(userText);
const assistantId = addAssistantMessage();
// Stream response and update
const response = await fetch('https://api.openai.com/v1/chat/completions', {
// ... fetch config ...
body: JSON.stringify({
messages: messages.map(({ role, content }) => ({ role, content })),
// ... other fields ...
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
if (dataStr === '[DONE]') continue;
try {
const json = JSON.parse(dataStr);
const token = json.choices?.[0]?.delta?.content || '';
updateMessage(assistantId, {
content: (messages.find((m) => m.id === assistantId)?.content || '') + token,
});
} catch (e) {
// Skip
}
}
}
}
updateMessage(assistantId, { status: 'complete' });
};
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong> {msg.content}
</div>
))}
</div>
);
}
Using useReducer for Complex State Logic
For chat apps with many state transitions (undo, edit, delete, retry), useReducer is cleaner:
import { useReducer } from 'react';
const initialState = {
messages: [],
selectedMessageId: null,
isLoading: false,
};
function chatReducer(state, action) {
switch (action.type) {
case 'ADD_USER_MESSAGE':
return {
...state,
messages: [
...state.messages,
{
id: action.payload.id,
role: 'user',
content: action.payload.content,
timestamp: Date.now(),
status: 'complete',
},
],
};
case 'ADD_ASSISTANT_MESSAGE':
return {
...state,
messages: [
...state.messages,
{
id: action.payload.id,
role: 'assistant',
content: '',
timestamp: Date.now(),
status: 'streaming',
},
],
};
case 'UPDATE_MESSAGE':
return {
...state,
messages: state.messages.map((msg) =>
msg.id === action.payload.id
? { ...msg, ...action.payload.updates }
: msg
),
};
case 'DELETE_MESSAGE':
return {
...state,
messages: state.messages.filter((msg) => msg.id !== action.payload.id),
};
case 'CLEAR_MESSAGES':
return {
...state,
messages: [],
};
case 'SET_LOADING':
return {
...state,
isLoading: action.payload,
};
default:
return state;
}
}
function ChatApp() {
const [state, dispatch] = useReducer(chatReducer, initialState);
const handleSendMessage = (content) => {
const userId = `msg_${Date.now()}`;
dispatch({ type: 'ADD_USER_MESSAGE', payload: { id: userId, content } });
const assistantId = `msg_${Date.now() + 1}`;
dispatch({ type: 'ADD_ASSISTANT_MESSAGE', payload: { id: assistantId } });
// ... streaming logic, then:
dispatch({
type: 'UPDATE_MESSAGE',
payload: { id: assistantId, updates: { status: 'complete' } },
});
};
return (
<div>
{state.messages.map((msg) => (
<div key={msg.id}>{msg.content}</div>
))}
</div>
);
}
useReducer scales better as your app grows. All state transitions are explicit, testable, and easy to debug.
Persisting Message History to localStorage
To preserve conversations across browser refreshes:
function usePersistentMessages(storageKey = 'chat_messages') {
const [messages, setMessages] = useState(() => {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : [];
});
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(messages));
}, [messages, storageKey]);
return [messages, setMessages];
}
// Usage
function ChatApp() {
const [messages, setMessages] = usePersistentMessages();
// Messages now persist across page reloads
return <div>{/* ... */}</div>;
}
Be cautious: localStorage has a 5–10 MB limit per origin. For large conversations, use IndexedDB or a backend database. Also, never store sensitive API responses or user data unencrypted in localStorage.
Preparing Message History for API Calls
Most AI APIs require the full conversation history. Transform your message state into the API format:
const historyForApi = messages.map(({ role, content }) => ({
role,
content,
}));
// Then pass to the API:
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: historyForApi,
stream: true,
}),
});
Key Takeaways
- Structure messages with
id,role,content,timestamp, andstatusfor full control. - Use
useStatefor simple chats;useReducerfor complex state machines. - Persist messages to
localStoragefor single-user apps; use a backend for multi-user or large conversations. - Always extract only
roleandcontentfields when calling AI APIs. - Implement undo/redo by maintaining a history stack of message arrays.
Frequently Asked Questions
What is the maximum size of a message history in localStorage?
Browsers typically limit localStorage to 5–10 MB per origin. A typical message (text only) is 100–500 bytes. You can store roughly 10,000–50,000 messages before hitting the limit. For larger apps, migrate to IndexedDB or a backend database.
How do I edit a message the user sent?
Store an isEdited boolean and an editedAt timestamp. Update the message by calling updateMessage(id, { content: newText, isEdited: true, editedAt: Date.now() }). Render a visual indicator that the message was edited.
Should I include system messages in the conversation history?
Yes, if the API expects them. OpenAI supports role: 'system' messages that set the assistant's behavior. Include them in the history passed to the API, but you may choose to hide them from the user UI.
What happens if a message fails to send?
Set status: 'error' and store the error message. Render a retry button. When the user retries, create a new message with a fresh ID rather than re-using the failed one.
How do I limit the conversation history to a maximum number of messages?
Implement a function that removes the oldest messages:
if (messages.length > 100) {
setMessages((prev) => prev.slice(-100));
}
This keeps only the last 100 messages in state and localStorage.