Seat-selection UIs (BookMyShow, District, Ticketmaster, etc.) look straightforward: show rows, show seats, let me click one.
But theatre layouts in the real world are messy:
- Seats are missing because there are aisles / stairs / empty spaces
- Some rows are shorter, some are split into blocks
- Different sections have different prices (“areas”)
- And the backend doesn’t hand you a clean 2D matrix — it’s usually sparse data
This post is basically a walkthrough of how I took a District API seat-layout response and turned it into a UI that can render any layout dynamically (without hardcoding positions).
This blog focuses on data structure + layout strategy
1) What the backend JSON looks like (District API response)
The JSON I used came from a District API response. The file is an array of layout objects, and each object contains a seatLayout.
The important nested path is:
-
seatLayout.colAreas.objArea[]→ pricing sections (“areas”) -
objArea.objRow[]→ rows -
objRow.objSeat[]→ seats
Here’s a minimal representative snippet (one layout → one area → one row → two seats):
[
{
"seatLayout": {
"name": "PVR",
"colAreas": {
"objArea": [
{
"AreaNum": 1,
"AreaDesc": "Recliners",
"AreaCode": "RE",
"HasCurrentOrder": true,
"AreaPrice": 385,
"objRow": [
{
"GridRowId": 1,
"PhyRowId": "P",
"objSeat": [
{
"GridSeatNum": 4,
"SeatStatus": "1",
"seatNumber": 1,
"displaySeatNumber": "1"
},
{
"GridSeatNum": 12,
"SeatStatus": "0",
"seatNumber": 5,
"displaySeatNumber": "5"
}
]
}
]
}
]
}
}
}
]
The detail that matters most for layout: GridSeatNum can jump (4 → 12). That jump is literally your aisle/gap. The backend doesn’t send “empty seats” for 5..11 — it just doesn’t include them.
What each field means for UI
-
AreaDesc/AreaPrice: what you show above a section (like “Recliners ₹385”) -
PhyRowId: the row label you show on the left (“A”, “B”, “P”, “N”…) -
GridRowId: row position (vertical placement) -
GridSeatNum: seat position within the row (horizontal placement) -
SeatStatus:-
"0"→ available -
"1"→ occupied/unavailable - everything else (for this dataset) → treat as gap
-
2) The core problem: sparse lists don’t render like a theatre
If you render seats like this:
row.objSeat.map(seat =>)
you’ll get something that technically shows seats… but it won’t look like the theatre map.
Because the UI needs to reflect things like:
- empty space between seat blocks
- aisles
- rows that are split in the middle
- missing rows (walking space)
A plain .map() compresses everything. The gaps disappear.
So the problem becomes:
How do you keep the “holes” in the data so the UI preserves spacing?
3) The strategy: indexed placement into pre-sized arrays
This is the entire trick.
Instead of rendering the sparse list directly, I convert it into something that behaves like a grid:
- Create an array of length X filled with
undefined - For every real item, place it at the index that represents its position
Then when you render the array, the missing indices stay missing — and that’s how you get gaps.
Why this works
- Missing indices stay
undefined→ that becomes a gap - Rendering is stable because index == coordinate
- A “gap cell” can be rendered as an invisible placeholder with the same size as a seat
4) Building a “grid” for the UI
There are two places where this idea shows up:
4.1 Row-level grid (vertical positioning)
Rows come as a list, but the GridRowId gives you a placement order/position. Sometimes there are intentional skips.
So you can build an array of rows where:
rows[rowIndex] = row- missing row indices remain
undefined
Later, when rendering:
-
undefinedrow → render a spacer row (or just keep the vertical gap)
That’s how you get gaps between rows without writing a bunch of layout-specific CSS.
4.2 Seat-level grid (horizontal positioning)
Same idea inside a row.
Seats come as a list, but each seat has GridSeatNum (think “column index”).
So you build:
seats[colIndex] = seat- missing indices remain
undefined→ aisle/gap
When you render the seats array:
- real seat → render a button
- missing seat → render a placeholder cell
5) Rendering becomes trivial after grid alignment
Once the data is “grid-aligned”, rendering is boring (which is what you want):
- render areas (pricing sections)
- for each area, render rows (including empty slots)
- for each row, render seats (including empty slots)
The only important UI rule is:
A gap must still occupy space.
So even if a seat slot is missing, the placeholder should have the same width/height as a real seat cell. That keeps everything aligned.
6) Flexibility: any seat at any 2D point
At this point every seat effectively has a coordinate:
- row index → Y
- column index → X
And because the backend is giving you these indices (GridRowId, GridSeatNum), you’re not guessing. You’re just placing items where the backend intended.
One quick note because the word “normalization” is overloaded:
Note: This is not Redux-style normalization (entities-by-id like
seatsById,rowsById, etc.).This is a grid alignment step for correct rendering geometry (gaps/aisles/spacing).
Redux normalization is great for state updates and selectors — but it doesn’t solve layout geometry on its own.
7) Handling multiple layouts in one JSON dump
In my project, the JSON can contain multiple layouts (it’s an array at the top level).
So instead of returning a single Area[], the builder returns:
-
Area[][]- outer array = layouts
- inner array = that layout’s areas
Then the UI can render them in a responsive grid:
- small screens: one below the other
- wide screens: two columns (
layout1 layout2, thenlayout3 layout4, …)
8) Practical notes / lessons learned
8.1 Stable sizing matters
If a seat cell is size-9, then your “gap cell” also needs to be size-9. Otherwise alignment breaks.
8.2 Don’t let layout collapse
If you rely purely on flex without placeholders, the browser will naturally collapse empty space. The whole point of the indexed placement is to preserve those empty slots.
8.3 Keys and rerenders
If you generate random keys on every render, React can’t reconcile properly and you’ll get unnecessary rerenders.
For production, prefer stable keys like:
area.idrow.idseat.id- plus
layoutIndexif you’re rendering multiple layouts
9) Summary
If I had to summarize the whole approach:
- The API gives you sparse lists + placement indices (
GridRowId,GridSeatNum) - Convert sparse lists into index-addressable arrays
- Render those arrays, and treat missing slots as real “gap cells”
That’s it. No magic. Just respecting the indices and making sure “missing” still takes up space.
If you’re reading this alongside the code:
- one step converts the District response into internal
Area/Row/Seatobjects - another step grid-aligns rows and seats into index-based arrays
- the UI renders those arrays, and missing indices become visible spacing
Optional future improvement:
- If seat selection/state management grows complex, add Redux-style normalization (entities-by-id + selectors). That optimizes updates/lookups — it’s separate from the grid geometry problem.

