React and ActionCable

Currently, our concert show page makes a fetch call to the server to determine the current status of the seats on the page, and it makes a POST call to put a seat on hold after you click on it. We can replace both of these calls with a single ActionCable subscription—granting, of course, that this is an absurdly minimalist implementation since we’re not doing a full security setup or complicated state transitions or anything like that.

ConcertChannel on the Server

Server side, we need to create a new ActionCable channel. This one will have the same subscribe method as our previous channel, but we need to add a method for our client side to call to actually reserve a ticket:

 class​ ConcertChannel < ApplicationCable::Channel
 def​ ​subscribed
  stream_from(​"concert_​​#{​params[​:concertId​]​}​​"​)
 end
 
 def​ ​unsubscribed
 # Any cleanup needed when channel is unsubscribed
 end
 
 def​ ​added_to_cart​(data)
  cart = ShoppingCart.​find_or_create_by​(​user_id: ​data[​"userId"​])
  cart.​add_tickets​(
 concert_id: ​data[​"concertId"​],
 row: ​data[​"row"​],
 seat_number: ​data[​"seatNumber"​],
 tickets_to_buy_count: ​data[​"ticketsToBuyCount"​],
 status: ​data[​"status"​]
  )
  result = Ticket.​grouped_for_concert​(data[​"concertId"​])
  ActionCable.​server​.​broadcast​(​"concert_​​#{​data[​"concertId"​]​}​​"​, result)
 end
 end

This is a little more complicated than our earlier channel. In the subscribed method, our stream_from name is now a dynamic string: concert_#{params[:concert_id]}". This suggests two things. First, it means that different concerts will have different streams, so we don’t have to worry on the client about seeing messages not meant for the page we are on. It also means that the client will need to pass the concert ID as a parameter when it subscribes.

And you probably noticed the added_to_cart method. This method is called when the client sends a message that tickets are to be added to the cart. It combines the functionality we had put in the ShoppingCartsController to update tickets and the functionality we had in TicketsController to return a data structure of all the tickets sorted by row. I’ve refactored that functionality into the Shopping Cart and Ticket classes, as shown here:

 class​ ShoppingCart < ApplicationRecord
  belongs_to ​:user
 
 def​ ​add_tickets​(
  concert_id:, row:, seat_number:, tickets_to_buy_count:, ​status:
  ​)
  seat_range = seat_number...seat_number + tickets_to_buy_count
  tickets = Ticket.​where​(
 concert_id: ​concert_id, ​row: ​row, ​number: ​seat_range
  ).​all
  tickets.​update​(​status: ​status, ​user: ​user)
 end
 end
 def​ self.​for_concert​(concert_id)
 return​ Ticket.​all​ ​unless​ concert_id
  Ticket.​where​(​concert_id: ​concert_id)
  .​order​(​row: :asc​, ​number: :asc​)
  .​all
  .​reject​(&​:refunded?​)
 end
 
 def​ self.​grouped_for_concert​(concert_id)
 return​ [] ​unless​ concert_id
  for_concert(concert_id).​map​(&​:to_concert_h​).​group_by​ { |t| t[​:row​] }.​values
 end

For our purposes, this means our channel expects a message that tells it to invoke the add_to_cart method with the same data that was passed to the original fetch endpoint, and it uses ActionCable.server.broadcast to send the same complete list of seat setup data back over the channel. This broadcast will go to all the clients subscribed to the channel, crucially including the client that sent the message in the first place.

On the client side, we need to subscribe to the channel. We also need to send a message to it when we click on a seat, and we need to respond to the resulting data. Mostly this involves a slight restructuring of the existing code.

In the Venue component, since we still need to do our data fetch in the useEffect hook but don’t want to poll the server anymore and instead want to subscribe to an ActionCable channel, we need to add a call to create the subscription:

 import​ * ​as​ React ​from​ ​"react"
 import​ VenueBody ​from​ ​"components/venue_body"
 import​ VenueHeader ​from​ ​"components/venue_header"
 import​ { createConsumer, Subscription } ​from​ ​"@rails/actioncable"
 
 interface​ VenueProps {
  concertId: number
  rowCount: number
  seatsPerRow: number
 }
 
 export​ ​interface​ TicketData {
  id: number
  row: number
  number: number
  status: string
 }
 export​ type RowData = TicketData[]
 export​ type VenueData = RowData[]
 
 let​ subscription: Subscription
 
 const​ Venue = ({
  concertId,
  rowCount,
  seatsPerRow,
 }: VenueProps): React.ReactElement => {
 const​ [ticketsToBuyCount, setTicketsToBuyCount] = React.useState(1)
 const​ [venueData, setVenueData] = React.useState<VenueData>([])
 
  React.useEffect(() => {
 const​ fetchData = ​async​ () => {
 const​ response = ​await​ fetch(​`/tickets.json?concert_id=​${concertId}​`​)
 const​ json = ​await​ response.json()
  setVenueData(json)
  }
  fetchData()
  }, [])
 
»if​ (subscription === ​undefined​) {
» subscription = createConsumer().subscriptions.create(
» { channel: ​"ConcertChannel"​, concertId: concertId },
» {
» received(data) {
» setVenueData(data)
» },
» }
» )
» }
 
 return​ (
  <>
  <VenueHeader
  seatsPerRow={seatsPerRow}
  setTicketsToBuyCount={setTicketsToBuyCount}
 /​​>
  <VenueBody
  concertId={concertId}
  seatsPerRow={seatsPerRow}
  rowCount={rowCount}
  ticketsToBuyCount={ticketsToBuyCount}
  venueData={venueData}
  subscription={subscription}
 /​​>
  <​/​​>
  )
 }
 
 export​ ​default​ Venue

We’ve taken this call to create the subscription out of useEffect for kind of quirky logistical reasons—useEffect happens after the component loads, whereas we want this subscription to be created before the rest of the component loads so that it can be passed down the React component tree. However, we don’t want the subscription to be created more than once, so we create a variable outside the component definition to store the subscription and then only create the subscription if it does not already exist.

The actual creation of the subscription is only slightly different from what we did in the Stimulus ActionCable example. Rather than have the first argument to create be the name of the channel, the first argument is a JavaScript object that has a channel attribute. That attribute determines which channel we are connecting to; every other attribute gets passed to the subscribed method as part of the params hash. As we saw earlier in our definition of the server-side channel, we need to pass the concert ID so that we can specify the name of the channel. In response to the data, in the received part of the code we call the same setVenueData method we were already calling in response to this data when it was coming from an HTTP request.

The place where the client side sends data back to the ActionCable channel happens in the Row component, so we need to pass the subscription to the parameters of the VenueBody and then to the Row. The VenueBody only changes to accommodate importing ActionCable, receiving the subscription as a parameter, and passing it along to each row.

The Row component changes to receive the parameter and then there’s a change to the onSeatChange method as well:

 function​ onSeatChange(seatNumber: number): ​void​ {
 const​ validStatus = validSeatStatus(seatNumber)
 if​ (validStatus === ​"invalid"​ || validStatus === ​"purchased"​) {
 return
  }
 const​ newSeatStatuses = updateSeatStatus(seatNumber)
  setSeatStatuses(newSeatStatuses)
» props.subscription.perform(​"added_to_cart"​, {
» concertId: props.concertId,
» row: props.rowNumber + 1,
» seatNumber: seatNumber + 1,
» status: newSeatStatuses[seatNumber],
» ticketsToBuyCount: props.ticketsToBuyCount,
» })
 }

The subscription object is passed through as props.subscription and then we call the perform method on it. The first argument to perform is the name of the method to be invoked on the receiving channel, and the second argument is a JavaScript object that is received as the data argument to the method being invoked. In this case, we’re passing all the same data that we would have been passing to the AJAX POST call.

And that is that. If you open two browsers side by side to the same concert page and click on a seat in one of them, the other browser will change to reflect that action almost immediately.

There is one other thing we can do, which is send the new ticket purchase back to our schedule page to adjust the number of tickets remaining to reflect the change. We have a couple of options here, depending on how much we want to upend the work we’ve already done in this channel. We could change the “sold out” messages we’re sending to the JSON channel to update the amount of tickets available and tweak our existing Stimulus code to handle that. Or we could use a Turbo Stream helper to automatically send a message when a Ticket is updated.

Both of these are viable options, but since we already have a Schedule channel to receive the data, we’ll use that. However, later in Appendix 1, Framework Swap, when we rewrite the React page in Stimulus, we’ll try it the other way.

We get this new feature with only a few small changes. First, our ConcertChannel needs to add a second broadcast when something is added to the cart:

 class​ ConcertChannel < ApplicationCable::Channel
 def​ ​subscribed
  stream_from(​"concert_​​#{​params[​:concertId​]​}​​"​)
 end
 
 def​ ​unsubscribed
 # Any cleanup needed when channel is unsubscribed
 end
 
 def​ ​added_to_cart​(data)
  cart = ShoppingCart.​find_or_create_by​(​user_id: ​data[​"userId"​])
  cart.​add_tickets​(
 concert_id: ​data[​"concertId"​],
 row: ​data[​"row"​],
 seat_number: ​data[​"seatNumber"​],
 tickets_to_buy_count: ​data[​"ticketsToBuyCount"​],
 status: ​data[​"status"​]
  )
  result = Ticket.​grouped_for_concert​(data[​"concertId"​])
  ActionCable.​server​.​broadcast​(​"concert_​​#{​data[​"concertId"​]​}​​"​, result)
» concert = Concert.​find​(data[​"concertId"​])
» ActionCable.​server​.​broadcast​(
»"schedule"​,
» {
»concerts: ​[
» {
»concertId: ​data[​"concertId"​],
»ticketsRemaining: ​concert.​tickets​.​unsold​.​count
» }
» ]
» }
» )
 end
 end

Here we have a second broadcast to the schedule channel. The data format has changed; it’s now an array called concerts, where each element in the array is an object with the properties concertId and ticketsRemaning. In this particular use case, the array will only have one element, but you could imagine a case where we need to refresh multiple concerts at once.

On the client side, we need to update the SoldOutDataController that receives the broadcast:

 import​ { Controller } ​from​ ​"stimulus"
 import​ { createConsumer, Channel } ​from​ ​"@rails/actioncable"
 
 interface​ ConcertRemainingData {
  concertId: number
  ticketsRemaining: number
 }
 
 export​ ​default​ ​class​ SoldOutDataController ​extends​ Controller {
 static​ targets = [​"concert"​]
  concertTargets: Array<HTMLElement>
  channel: Channel
  started: ​boolean
 
  connect(): ​void​ {
 if​ (​this​.channel) {
 return
  }
 this​.started = ​true
 this​.channel = ​this​.createChannel(​this​)
  }
 
  createChannel(source: SoldOutDataController): Channel {
 return​ createConsumer().subscriptions.create(​"ScheduleChannel"​, {
  received({ concerts }) {
  source.updateData(concerts)
  },
  })
  }
 
  updateData(concerts: ConcertRemainingData[]): ​void​ {
  concerts.forEach(({ concertId, ticketsRemaining }) => {
 this​.concertTargets.forEach((e) => {
 if​ (e.dataset.concertIdValue === concertId.toString()) {
  e.dataset.concertTicketsRemainingValue = ticketsRemaining.toString()
  e.dataset.concertSoldOutValue = (
  ticketsRemaining === 0
  ).toString()
  }
  })
  })
  }
 }

Three changes were made here. First, at the top of the file, there’s a new interface ConcertRemainingData that contains the concertId and the ticketsRemaining. Strictly speaking, this isn’t necessary, but TypeScript likes to know this information, and we do have it. Second, the createChannel changes slightly to expect concerts as the top-level key in the incoming JSON when data is received. And third, the updateData method changes too. For each entry in the data, it checks all the concert targets, and if the IDs match, it updates the tickets remaining and sold-out information in the element dataset. (For performance reasons, you’d probably want the concertTargets to already be indexed to avoid the inner loop, but it’s not a significant problem yet.)

And we have one more small change to make to ensure the display actually updates. To do this, we need to change the name of the method in the ConcertController to watch the tickets remaining value, like this:

 import​ { Controller } ​from​ ​"stimulus"
 
 export​ ​default​ ​class​ ConcertController ​extends​ Controller {
 static​ targets = [​"tickets"​]
  ticketsTarget: HTMLElement
 
 static​ values = { id: Number, soldOut: Boolean, ticketsRemaining: Number }
  soldOutValue: ​boolean
  ticketsRemainingValue: number
 
  ticketsRemainingValueChanged(): ​void​ {
 if​ (​this​.ticketsRemainingValue === 0) {
 this​.ticketsTarget.innerText = ​"Sold Out"
  } ​else​ {
 const​ ticketsRemaining = ​`​${​this​.ticketsRemainingValue}​ Tickets Remaining`
 this​.ticketsTarget.innerText = ticketsRemaining
  }
  }
 }

And this works. Now if you open a browser to the schedule page and a different browser to a concert page, selecting seats in the concert page will update the tickets remaining list on the schedule page.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.134.118.95