← Liana Grigory
Insights

Two screens, not one screen with two states

An inbox and a conversation never appear together on a phone. Treating them as one component with a flag fakes navigation badly; giving each its own screen lets the platform do it properly.

By Liana Grigory · 2 October 2026 · 7 min read

The messaging tab in a marketplace app has two jobs. It shows the list of conversations, and it shows one conversation. On a wide screen those can sit side by side. On a phone they cannot, and the way that conflict is resolved decides whether the feature feels solid or quietly broken.

The tempting implementation is one screen with a mode. The inbox and the thread are both in the markup, a piece of state says which is active, and CSS hides the other. It is fewer files, it shares a header, and it demos well. I have built it that way, and I have since replaced it with two screens in every app where it existed. This piece is about why.

What goes wrong with one screen and a flag

None of the failures is dramatic. Each is small, intermittent and hard to reproduce, which is exactly what makes the pattern expensive.

  • Scroll position bleeds across. Both lists live in the same scrolling context, or in nested ones. Open a thread from far down the inbox and the thread opens scrolled to somewhere in the middle. Go back and the inbox is at the top. Neither is what anyone expected.
  • The message bar shows up where it should not. The input that belongs to a thread is fixed to the bottom of the viewport. When the state flag and the CSS disagree for one frame, or a class is left behind after navigation, the bar appears over the inbox, or two bars stack on top of the tab bar.
  • Back does not mean back. If opening a thread only flips a flag, the browser and the native shell know nothing about it. The system back gesture leaves the messaging tab entirely, instead of returning to the inbox, because as far as history is concerned there was nothing to return to.
  • Both states are live at once. Hidden is not absent. A hidden inbox still holds its event handlers, its focus targets and whatever it fetched. A screen reader can reach elements that are invisible. A query for "the conversation list" finds one in a screen that is supposed to be showing a thread.
  • Every new feature has to know about the mode. Unread badges, typing indicators, an attachment picker: each one has to ask which half is showing before it does anything. The flag spreads into code that should not care.

Each of these can be patched. The patches accumulate into a component whose real behaviour lives in a set of conditions nobody can hold in their head, and the next change breaks one of them.

Two screens, each with one job

The structure I use now treats the inbox and the thread as separate screens with separate addresses. The inbox is a list of conversation rows: an avatar, the other person's name, the last message, a timestamp and an unread marker. Tapping a row navigates to the thread, which is a different screen with its own layout: a header with a back control and the other person's name, a scrolling message area, and a message bar pinned to the bottom of that screen only.

The difference is not cosmetic. Because the thread is a navigation, it gets a history entry. The system back gesture returns to the inbox because there is an inbox behind it. The inbox restores its own scroll position because it is its own document state. The message bar cannot appear over the inbox, because the inbox does not contain one. There is nothing to hide, because there is only ever one thing on screen.

The class names stay the same across every app that has this feature, so they can be read side by side and a fix found in one can be carried to the others. That matters more than it sounds: the same two-screen structure, copied faithfully, means the same bugs are fixed once.

The layout of a thread is its own problem

Once the thread is a screen, its layout can be designed for exactly one thing. The header sits at the top, the message bar at the bottom, and the message list takes the space between and is the only thing that scrolls. Getting that right depends on how the surrounding app scrolls, which is why the height of the thread is derived for each app rather than copied. An app whose document scrolls needs the thread to claim a fixed share of the viewport. An app whose shell already owns the scrolling needs the thread to fill its container, and copying the first app's value into the second creates a third scroller that pushes the tab bar off the screen.

On a phone, the keyboard is the other constraint. When it opens, the visible area shrinks, and a message bar fixed to the layout viewport can end up behind it. Keeping the bar inside the thread's own flex layout, rather than fixed to the window, lets it move with the space that is actually available. The last message should stay in view as the keyboard opens, which is a matter of scrolling the message area to the end when its size changes, not of guessing a keyboard height.

Arrows that tell the truth

The thread's back control has an obvious destination, the inbox, so it is always present on the thread. The inbox, reached from the tab bar, has nothing behind it within the messaging feature, so it does not draw a back control at all. That follows a rule I apply everywhere: a control appears only while it leads somewhere. A back arrow on the inbox that sends the user to the home page, or does nothing, is a control that lies about what it does, and that is worse than no control.

Cost follows the structure

Splitting the screens also makes the data access easier to reason about. The inbox reads one small, limited list of the signed-in user's own conversations, enough to draw the rows. The thread reads the messages of one conversation, again with a limit, and only when it is opened. Neither holds a live listener open in the background; a conversation is fetched when it is viewed and when a message is sent. With one screen holding both states, it is very easy to end up fetching the thread data for whichever conversation was last open while the user is looking at the inbox, and paying for reads that are never shown.

The general rule

When a component has two states that never appear together on the target device, and each has its own layout, its own scroll and its own controls, those are two screens. Giving them separate addresses lets the platform do the work a mode flag was trying to fake: history, back, scroll restoration and focus all behave correctly because they are being used for what they were built to do. The code is slightly longer and the behaviour is much shorter to describe, which is the trade I would make every time.

Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.

Home · Terms of Use · Privacy Notice