Chat UI Is This Hard?
Honestly, I thought the chat UI would be the easy part.
Show messages, take input, send. How hard could it be.
It wasn't easy.
The keyboard covers the input bar
The first thing I hit was the keyboard. Tap the input field, the keyboard comes up, and it covers the input bar completely.
React Native has something called
KeyboardAvoidingView. It's supposed to push the layout up when the keyboard appears. Naturally, I used it.
Inside Expo Router's tab structure, it doesn't work.
The tab bar takes up 82px at the bottom of the screen.
KeyboardAvoidingView doesn't know that. It miscalculates the keyboard offset and the layout goes wrong.
I ended up handling it directly.
Keyboard.addListener('keyboardWillShow', (e) => { const kbHeight = e.endCoordinates.height setPadding(kbHeight - TAB_BAR_HEIGHT) // subtract the tab bar })
Skip
KeyboardAvoidingView. Read the keyboard height directly, apply it as padding. The formula is different depending on whether you're inside a tab or in a full-screen context.
Swipe-right to reply
Like KakaoTalk — swipe a bubble to the right and it enters reply mode.
Built with
PanResponder. Pull 50px or more and reply mode activates; release and it springs back. A quote bar appears above the input, and when you send, the original message shows as a collapsed reference.
The implementation itself wasn't the hard part. Fine-tuning was. 50px felt right after testing — too low and it triggers when you're trying to scroll, too high and intentional swipes don't register.
History wasn't loading right
The most baffling bug.
Open a room, messages appear. But in some rooms, scrolling up goes nowhere. Like the history was frozen at a certain point.
The strange part was it was inconsistent. Some rooms were fine. Some weren't. Same code.
The cause was the query.
// this query was the problem .order('created_at', { ascending: true }).limit(50)
ascending: true with limit(50) fetches the oldest 50 messages. In rooms with fewer than 50 messages, you get everything — looks normal. In rooms with more than 50, the newest messages never appear.
That's why it was inconsistent room by room.
// correct .order('created_at', { ascending: false }).limit(50) // then flip them into chronological order .then(messages => messages.reverse())
Fetch the latest 50 in descending order, then reverse before rendering. Obvious in retrospect. Impossible to see before you know.
Optimistic updates vs. Realtime
I built optimistic updates — send a message, it appears on screen immediately, then gets replaced with the confirmed server version once the API responds.
The problem: Realtime INSERT can arrive before the API response.
My message hits the database and bounces back through Realtime before I've even gotten the API reply. That creates two copies of the same message on screen.
The fix is to match by
client_id when replacing, and carry over any local data (like reply_to) from the optimistic message — the Realtime row coming back from the DB won't have that joined data.
Things that seemed like they should just work, didn't.
There's a lot hidden inside something as common as a chat UI. I didn't know how much until I was in it. That's the whole game at this point — finding out what I didn't know I didn't know.