📓 3.3.3.5 Showing Ticket Detail
In the lesson Planning Our Application: Part 3, we planned how to add a TicketDetail component to our Help Queue. Now let's build it!
The Big Picture
Before we write any code, let's understand what we're trying to accomplish:
- A user sees the ticket list
- They click on a ticket
- The detail view for that ticket appears
- They click "Return to Ticket List" to go back
To make this work, we need to answer two questions:
- How does
TicketControlknow which ticket was clicked? We'll pass a function down toTicketthat reports back when clicked. - How does the correct detail view appear? We'll store the selected ticket in
TicketControl's state and use conditional rendering to showTicketDetailwith the right ticket when appropriate.
Think of it like a restaurant: the waiter (Ticket) doesn't cook the food - they just take your order and pass it back to the kitchen (TicketControl). The kitchen decides what to do with that information.
Our Roadmap
We'll work through this in seven steps:
- Create a placeholder
TicketDetailcomponent - Add
selectedTicketstate toTicketControl - Write a function to handle ticket selection
- Update the conditional rendering in
TicketControl - Pass the function down through
TicketListtoTicket - Display the actual ticket data in
TicketDetail - Fix the "Return to Ticket List" button
Let's go!
Step 1: Create the TicketDetail Component
We'll start simple - just a placeholder that proves our component works. Create a new file called TicketDetail.tsx in the src/components folder with this content:
function TicketDetail() {
return (
<>
<h1>Ticket Detail</h1>
<p>You clicked on a ticket!</p>
<hr />
</>
);
}
export default TicketDetail;
Nothing fancy yet. We'll add the actual ticket data later once we confirm everything is connected properly.
Step 2: Add State to Track the Selected Ticket
Now we need TicketControl to remember which ticket (if any) the user has selected.
Add this new state variable alongside the existing ones in TicketControl.tsx:
// Inside the TicketControl component function
const [formVisibleOnPage, setFormVisibleOnPage] = useState(false);
const [mainTicketList, setMainTicketList] = useState<TicketData[]>([]);
const [selectedTicket, setSelectedTicket] = useState<TicketData | null>(null);
Why null? Because when the app loads, no ticket is selected yet (the user has not clicked on one). We use null to represent "nothing selected." Later we'll update this state to hold the actual ticket object when the user clicks a ticket.
Notice that we give useState an explicit type of TicketData | null. Just like the empty array we typed in an earlier lesson, TypeScript can't work out the full type from the starting value on its own. If we wrote useState(null), TypeScript would decide this state can only ever hold null, and it would complain the moment we tried to store a ticket in it. Writing useState<TicketData | null>(null) says "this holds either a ticket or nothing," which is exactly what we mean.
Step 3: Write the Selection Handler Function
When a user clicks a ticket, we need a function that:
- Takes the ticket's
id - Finds the matching ticket in our list
- Stores it in
selectedTicketstate
Add this function inside TicketControl:
const handleChangingSelectedTicket = (id: string) => {
const selection = mainTicketList.find(ticket => ticket.id === id) ?? null; // Find ticket with given id
setSelectedTicket(selection); // Store the selected ticket in state
};
Breaking this down:
- The
idparameter is typed as astring, matching theidproperty on ourTicketDatatype find()returns the first ticket whereticket.id === id- If nothing matches,
find()returnsundefined. Our state holdsTicketData | null, notTicketData | undefined, so we use?? nullto turn thatundefinedintonull. Without it, TypeScript would flag the call tosetSelectedTicket(). setSelectedTicket(selection)updates our state, triggering a re-render
Why find() instead of filter()? find() returns a single item (the first match), while filter() returns an array of all matches. Since ticket IDs are unique, we only need one ticket, so find() is the right choice here.
Step 4: Update the Conditional Rendering
Now we need to actually show the TicketDetail component with the selected ticket when a ticket is selected.
First, import the TicketDetail component at the top of TicketControl.tsx:
import TicketDetail from './TicketDetail';
Then update the conditional rendering logic. Here's the key insight: we now have three possible views, and we need to check them in the right order:
- 1. If a ticket is selected, show
TicketDetailwith that ticket. - If no ticket is selected, either show the form or the list depending on
formVisibleOnPagestate:- 2. If
formVisibleOnPageis true, showNewTicketForm. - 3. Otherwise, show
TicketList.
- 2. If
let currentlyVisibleState;
let buttonText;
if (selectedTicket !== null) {
// A ticket is selected → show its details
currentlyVisibleState = <TicketDetail ticket={selectedTicket} />;
buttonText = "Return to Ticket List";
} else if (formVisibleOnPage) {
// No ticket selected, but form should show → show the form
currentlyVisibleState = <NewTicketForm onNewTicketCreation={handleAddingNewTicketToList} />;
buttonText = "Return to Ticket List";
} else {
// Default → show the ticket list
currentlyVisibleState = (
<TicketList
ticketList={mainTicketList}
onTicketSelection={handleChangingSelectedTicket}
/>
);
buttonText = "Add Ticket";
}
Notice two important things:
-
We check
selectedTicketfirst. Order matters! If a ticket is selected, we want to show its details regardless of whatformVisibleOnPagesays. -
We pass
handleChangingSelectedTickettoTicketListas a prop calledonTicketSelection. This is how we'll eventually connect clicks to our handler.
There's a bonus to writing if (selectedTicket !== null). Inside that block, TypeScript knows selectedTicket can't be null anymore, so it treats the value as a plain TicketData. That's why we can pass it straight to TicketDetail, which requires a real ticket. TypeScript follows our conditionals and narrows types along with us.
Step 5: Pass the Function Through TicketList to Ticket
Here's a tricky part. Our click handler lives in TicketControl, but the actual clicking happens in Ticket. We need to pass the function through TicketList to get it there.
Think of it like passing a note in class: TicketControl writes the note, hands it to TicketList, who hands it to Ticket.
Update TicketList
import Ticket from "./Ticket";
import { type TicketData } from '../types';
type TicketListProps = {
ticketList: TicketData[];
onTicketSelection: (id: string) => void;
};
function TicketList({ ticketList, onTicketSelection }: TicketListProps) {
return (
<>
<hr />
{ticketList.map((ticket) =>
<Ticket
onTicketClick={onTicketSelection}
names={ticket.names}
section={ticket.section}
issue={ticket.issue}
id={ticket.id}
key={ticket.id}
/>
)}
</>
);
}
export default TicketList;
What changed:
- We receive
onTicketSelectionfromTicketControland destructure it in the function signature - We pass it down to each
TicketasonTicketClick - We pass the ticket's
idas a prop (eachTicketneeds to know its own ID) - We updated
TicketListPropsto includeonTicketSelection, typed as(id: string) => void: a function that takes a stringidand returns nothing
Update Ticket
Now Ticket has access to the click handler function. Let's make it call that function when clicked:
type TicketProps = {
names: string;
section: string;
issue: string;
id: string;
onTicketClick: (id: string) => void;
};
function Ticket({ names, section, issue, id, onTicketClick }: TicketProps) {
return (
<>
<div onClick={() => onTicketClick(id)}>
<h3>{section} - {names}</h3>
<p><em>{issue}</em></p>
<hr />
</div>
</>
);
}
export default Ticket;
The crucial line:
<div onClick={() => onTicketClick(id)}>
Why the arrow function? If we wrote onClick={onTicketClick(id)}, the function would run immediately when the component renders, not when clicked! The arrow function says "when clicked, then call this function with this ID."
At this point, if you click a ticket, you should see the TicketDetail placeholder we made earlier. (Note that the Return to Ticket List button won't work yet; we'll fix that before the end of this lesson.)
Note that we also updated TicketProps to include id and onTicketClick.
Step 6: Display the Actual Ticket Data
Now that clicking works, let's update TicketDetail to show real ticket information:
import { type TicketData } from '../types';
type TicketDetailProps = {
ticket: TicketData;
};
function TicketDetail({ ticket }: TicketDetailProps) {
return (
<>
<h1>Ticket Detail</h1>
<h3>{ticket.section} - {ticket.names}</h3>
<p><em>{ticket.issue}</em></p>
<hr />
</>
);
}
export default TicketDetail;
Notice that our ticket prop is typed as a whole TicketData object rather than as a list of separate names, section, and issue props. Because TicketControl already has the complete ticket object in state, it's simpler to hand the whole thing over at once. TypeScript still checks every property we reach for, so ticket.sekction would be caught as a typo immediately.
Step 7: Fix the "Return to Ticket List" Button
Try clicking a ticket, then clicking "Return to Ticket List." Uh oh, it shows the form instead of the list!
The problem is in the handleClick function in the TicketControl component. We need to clear selectedTicket when returning to the ticket list because otherwise the conditional rendering still thinks a ticket is selected.
const handleClick = () => {
if (selectedTicket !== null) {
// We're on the detail page → go back to the list
setFormVisibleOnPage(false);
setSelectedTicket(null);
} else {
// We're on the form or list → toggle the form
setFormVisibleOnPage(!formVisibleOnPage);
}
};
The logic:
- If
selectedTickethas a value, we must be viewing ticket details. Clear it to go back to the list. - Otherwise, toggle the form like before.