Skip to main content

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, and status for full control.
  • Use useState for simple chats; useReducer for complex state machines.
  • Persist messages to localStorage for single-user apps; use a backend for multi-user or large conversations.
  • Always extract only role and content fields 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.

Further Reading