Acquiring Data in React with useState

Now we are going to get our React page to also interact by making API calls to server. When last we left our React page in Dynamic Styled Components, it was displaying the seating information for one specific concert. What we’d like it to do now is get that seating information from the server and update it if it changes. This will use the fetch method we’ve already seen, as well as a new React hook called useEffect.

Updating the seating information involves adding the following features to the page:

  • When the React components load, they need to get the current seating status from the server.

  • That status needs to get passed down from the Venue component to the seat component that uses the status to display. In this chapter, we’re going to keep the status in our component; in Chapter 12, Managing State in React, we’ll look at how we can keep the status in a central global repository.

  • We need to make sure a user can’t adjust a seat that is already sold. (This is a UI bit we didn’t do when we were working on this page earlier.)

  • When the user does click on a seat, adding it to the shopping cart, we want the page to update the server so that any other user looking at the page sees the updated status.

That’s a lot, but most of it is similar to React features we’ve already used. Note that we don’t need to explicitly handle authentication, since our React code is not a single-page app; the Rails server is handling it and will handle it in the Rails session on our API calls.

Let’s look at the new React parts first. Once that’s done, we need to add a couple of new Rails controller actions, but we’ll talk about them after we see how the React code works. We have a hierarchy of components here—Venue to VenueBody to Row to Seat—and they all change at least a little bit.

We start with a small change to the top-level call in our pack entry point, just changing it so the setup data is being passed in the props.

First we need to change the concert show page to send some props:

 <div id=​"react-element"
  data-row-count=​"​​<%=​ @concert.​venue​.​rows​ ​%>​​"
  data-seats-per-row=​"​​<%=​ @concert.​venue​.​seats_per_row​ ​%>​​"
  data-concert-id=​"​​<%=​ @concert.​id​ ​%>​​"​></div>
 import​ * ​as​ React ​from​ ​"react"
 import​ * ​as​ ReactDOM ​from​ ​"react-dom"
 import​ Venue ​from​ ​"components/venue"
 
 document.addEventListener(​"turbo:load"​, () => {
 const​ element = document.getElementById(​"react-element"​)
 if​ (element) {
  ReactDOM.render(
  <Venue
  rowCount=​{​parseInt(element.dataset.rowCount, 10)​}
  seatsPerRow=​{​parseInt(element.dataset.seatsPerRow, 10)​}
  concertId=​{​parseInt(element.dataset.concertId, 10)​}
  />,
  document.getElementById(​"react-element"​)
  )
  }
 })

Now, we need to update the Venue to use that data.

The Venue Component

The Venue component fetches the data from the server so that each individual Seat component isn’t making its own data request. To acquire this data when the component renders, we need something analogous to the Stimulus connect method, which is automatically invoked when the component is added. We can do that in React using a hook called useEffect.

Here’s the entire Venue component:

 import​ * ​as​ React ​from​ ​"react"
 import​ VenueBody ​from​ ​"components/venue_body"
 import​ VenueHeader ​from​ ​"components/venue_header"
 
 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[]
 
 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()
 const​ interval = setInterval(() => fetchData(), 1000 * 60)
 return​ () => clearInterval(interval)
  }, [])
 
 return​ (
  <>
  <VenueHeader
  seatsPerRow=​{​seatsPerRow​}
  setTicketsToBuyCount=​{​setTicketsToBuyCount​}
  />
  <VenueBody
  concertId=​{​concertId​}
  seatsPerRow=​{​seatsPerRow​}
  rowCount=​{​rowCount​}
  ticketsToBuyCount=​{​ticketsToBuyCount​}
  venueData=​{​venueData​}
  />
  </>
  )
 }
 
 export​ ​default​ Venue

Let’s start with the familiar. We added a concertId to the VenueProps. We’ll wind up passing this all the way down to the Seat so that we can identify the page when we communicate with the server.

We also added some new types: TicketData, which represents a single ticket and is an id, row, seat, and status. Then we used the TypeScripts type command to define RowData as an array of TicketData objects and VenueData as an array of RowData, which makes VenueData a two-dimensional array of tickets. (These aliases are really only there for readability.)

We added the concertId to the props list in the parameter, and we added a new useState hook for what will be the incoming VenueData. At the end of the component, we are now passing the venue data and concert id to the VenueBody child component.

Using useEffect

The Venue component uses a new hook called useEffect. The useEffect hook allows a React component to trigger behavior when the component renders and to allow that behavior to have a side effect.

In general, React tries to be a functional representation of the world, which means a React component wants to convert state directly into DOM elements without having any other effect on the system. However, we often need to do things that are not purely functional. We might need to log output. We might need to send events to other items or register to receive events from other items. In our case we want to retrieve the seating data from the server, which is updating our state based on an external source. Collectively, the things you might do that aren’t directly converting state to DOM elements are called side effects.

React very much wants you to avoid side effects in the part of the code that renders output and provides the useState hook as a place to have non-functional interactions with the rest of the world. A component can have multiple useEffect hooks, and in fact, the React core teams’ recommendation is to have many small effect hooks rather than one large one.

At its simplest, the useEffect hook takes just one argument. This argument is a function that presumably performs a side effect. This function is, by default, invoked after rendering, every time the component renders.

If you look at the useEffect method in the Venue component, you’ll see that it’s a little more involved than a simple function—there’s actually an internal function called fetchData that is declared as async. Inside the fetchData function, we call our Rails server, await the response, and then use the setVenueData function from our useState hook to update the state of the component. The body of the useEffect argument calls that internal fetchData function and embeds it in a setInterval call so that it will be reinvoked every minute.

The reason for this rigmarole around the internal method is that you are not allowed to declare the argument to useEffect to be async in its own right. (To be clear, TypeScript won’t let you make the argument async; you can get away with it in plain JavaScript, but it’s considered a bad idea.) Hence, we use the workaround here to give us an asynchronous effect. There are also third-party add-ons that have written custom asynchronous versions of useEffect.[76]

Here’s what happens now when the Venue component is rendered:

  • The component renders with the venueData set to its initial value, an empty array.

  • The function argument to useEffect is called, triggering a call to the server and eventually updating the value of the venue data.

  • The updated venue data value triggers a rerender of the Venue component.

By default, that rerender would trigger another invocation of the argument to useEffect, which we don’t want. We want to be more in control of the calls to the server, and we definitely don’t want that setInterval to be invoked multiple times.

The second argument to useEffect, which is optional, allows us more control over when the useEffect function is invoked. There are three possible values for that second argument:

  • If the second argument is not present, the effect is invoked every time the component renders.

  • If the second argument is an empty array, the effect is only invoked the first time the component renders and never again.

  • If the second argument is an array containing values, those values are typically values from the props array. (In our case, we might have something like [rows, concertId].) If that array contains values, the effect is only invoked on rendering if one of the values in the array changed. Under most circumstances, if you have props referenced in the useEffect, then every props value so referenced should be part of this array.

You can use this second argument for performance purposes to minimize calls to the external effect, and it can also be used as we’re using it here, to prevent an infinite cascade of changing values and rerenders.

You might also need to perform cleanup between renders or when the component unmounts. This can be done with the useEffect hook, but the syntax is convoluted. If useEffect has a return value, then that return value is itself a function. The returned function is meant to be a cleanup and is invoked before the next render of the component begins and again when the component unmounts from the React component tree. The cleanup only happens when useEffect is invoked, so if you are using the second argument to useEffect to only trigger the effect when certain props change, then the cleanup is only performed before the next useEffect.

In our case, the return value () => clearInterval(interval) is invoked when the component unmounts to prevent the code from hitting the server once a minute until the end of time. Since we have a second argument of [], the useEffect hook is never retriggered, and the cleanup only happens when the component unmounts.

Passing Data Through Our React Code

Following our change through, the VenueBody component doesn’t change much, it just takes in the extra values and passes them along, giving each row its corresponding RowData:

 import​ * ​as​ React ​from​ ​"react"
 import​ Row ​from​ ​"components/row"
 import​ { VenueData } ​from​ ​"components/venue"
 
 interface​ VenueBodyProps {
  concertId: number
  rowCount: number
  seatsPerRow: number
  ticketsToBuyCount: number
  venueData: VenueData
 }
 
 const​ rowItems = ({
  concertId,
  rowCount,
  seatsPerRow,
  ticketsToBuyCount,
  venueData,
 }) => {
 const​ rowNumbers = Array.​from​(Array(rowCount).keys())
 return​ rowNumbers.map((rowNumber: number) => (
  <Row
  concertId=​{​concertId​}
  key=​{​rowNumber​}
  rowData=​{​venueData[rowNumber]​}
  rowNumber=​{​rowNumber​}
  seatsPerRow=​{​seatsPerRow​}
  ticketsToBuyCount=​{​ticketsToBuyCount​}
  />
  ))
 }
 
 export​ ​const​ VenueBody = (props: VenueBodyProps): React.ReactElement => {
 return​ (
  <table className=​"table"​>
  <tbody>​{​rowItems(props)​}​</tbody>
  </table>
  )
 }
 
 export​ ​default​ VenueBody

The Row component carries a lot of load here. It maintains a client-side status of each seat, which is based on its ticket status and also the number of tickets the user is looking to buy, so as to prevent buying tickets so close to an already sold ticket that you can’t buy an entire block. It also has the click handler invoked when a user clicks on a seat.

Here’s the new version of the row:

 import​ * ​as​ React ​from​ ​"react"
 import​ Rails ​from​ ​"@rails/ujs"
 import​ Seat ​from​ ​"components/seat"
 import​ { RowData, TicketData } ​from​ ​"components/venue"
 
 interface​ RowProps {
  concertId: number
  rowData: RowData
  rowNumber: number
  seatsPerRow: number
  ticketsToBuyCount: number
 }
 
 const​ Row = (props: RowProps): React.ReactElement => {
 const​ [seatStatuses, setSeatStatuses] = React.useState(
  Array.​from​(Array(props.seatsPerRow).keys()).map(() => ​"unsold"​)
  )
 
  React.useEffect(() => {
 if​ (props.rowData) {
  setSeatStatuses(
  props.rowData.map((ticketData: TicketData) => ticketData.status)
  )
  }
  }, [props.rowData])
 
 function​ isSeatValid(seatNumber): ​boolean​ {
 if​ (seatNumber + props.ticketsToBuyCount > props.seatsPerRow) {
 return​ ​false
  }
 for​ (​let​ i = 1; i < props.ticketsToBuyCount; i++) {
 const​ seatStatus = seatStatuses[seatNumber + i]
 if​ (seatStatus === ​"held"​ || seatStatus === ​"purchased"​) {
 return​ ​false
  }
  }
 return​ ​true
  }
 
 function​ validSeatStatus(seatNumber): string {
 const​ seatStatus = seatStatuses[seatNumber]
 if​ (seatStatus === ​"held"​ || seatStatus === ​"purchased"​) {
 return​ seatStatus
  } ​else​ {
 return​ isSeatValid(seatNumber) ? ​"unsold"​ : ​"invalid"
  }
  }
 
 function​ newState(oldStatus: string): string {
 if​ (oldStatus === ​"unsold"​) {
 return​ ​"held"
  } ​else​ ​if​ (oldStatus === ​"held"​) {
 return​ ​"unsold"
  } ​else​ {
 return​ ​"invalid"
  }
  }
 
 function​ updateSeatStatus(seatNumber: number): string[] {
 return​ seatStatuses.map((status: string, index: number) => {
 if​ (
  index >= seatNumber &&
  index < seatNumber + props.ticketsToBuyCount
  ) {
 return​ newState(seatStatuses[seatNumber])
  } ​else​ {
 return​ status
  }
  })
  }
 
 function​ onSeatChange(seatNumber: number): ​void​ {
 const​ validStatus = validSeatStatus(seatNumber)
 if​ (validStatus === ​"invalid"​ || validStatus === ​"purchased"​) {
 return
  }
 const​ newSeatStatuses = updateSeatStatus(seatNumber)
  setSeatStatuses(newSeatStatuses)
  fetch(​`/shopping_carts`​, {
  method: ​"POST"​,
  headers: {
 "X-CSRF-Token"​: Rails.csrfToken(),
 "Content-Type"​: ​"application/json"​,
  },
  body: JSON.stringify({
  concertId: props.concertId,
  row: props.rowNumber + 1,
  seatNumber: seatNumber + 1,
  status: newSeatStatuses[seatNumber],
  ticketsToBuyCount: props.ticketsToBuyCount,
  }),
  })
  }
 
 const​ seatItems = Array.​from​(Array(props.seatsPerRow).keys()).map(
  (seatNumber: number) => {
 return​ (
  <Seat
  clickHandler=​{​onSeatChange​}
  key=​{​seatNumber​}
  seatNumber=​{​seatNumber​}
  status=​{​validSeatStatus(seatNumber)​}
  />
  )
  }
  )
 
 return​ <tr className=​"h-20"​>​{​seatItems​}​</tr>
 }
 
 export​ ​default​ Row

There are some small changes here because we are passing concertId and rowData into the props. There are a few bigger changes as well.

First, we have a useEffect hook here to manage the seat status. Each row maintains a local status of seats based on the ticket data, which is updated by a local click. The useEffect hook here regenerates that local status when the row data changes. So, to continue the workflow, if the Venue gets a new set of row data, it will get passed through to this component, and the effect hook will fire and update the status of the seats to reflect the new data.

There are a few relatively minor changes to deal with the new “purchased” status and then another big change in the onSeatChange click handler.

We’ve updated this method to make a fetch call back to the server to update data there. We’re specifying a new endpoint, /shopping_carts, and that the call is a POST.

We’re also adding two headers. The X-CSRF-Token header is used by Rails to verify that the form actually comes from the application itself and to prevent a cross-site scripting attack. The value Rails.csrfToken() is defined for us by the @rails/ujs package. Setting the content type to JSON lets us send the body as JSON, which we then do, setting a JSON object with the concertId, the row and seatNumber, the new status, and the number of tickets to buy. The endpoint will update ticket values, and subsequent calls to query for ticket status will show these tickets as held.

Finally, the Seat itself mostly just changes by adding the new data and adding a status color for “purchased.” Because we’re managing the status here, we no longer have to pass TicketData to the Seat.

 import​ * ​as​ React ​from​ ​"react"
 import​ styled ​from​ ​"styled-components"
 
 const​ stateColor = (status: string): string => {
 if​ (status === ​"unsold"​) {
 return​ ​"white"
  } ​else​ ​if​ (status === ​"held"​) {
 return​ ​"green"
  } ​else​ ​if​ (status === ​"purchased"​) {
 return​ ​"red"
  } ​else​ {
 return​ ​"yellow"
  }
 }
 
 interface​ SquareProps {
  status: string
  className?: string
 }
 const​ buttonClass = ​"p-4 m-2 border-black border-4 text-lg"
 
 const​ ButtonSquare = styled.span.attrs({
  className: buttonClass,
 })<SquareProps>​`
  background-color: ​${(props) => stateColor(props.status)}​;
  transition: all 1s ease-in-out;
 
  &:hover {
  background-color: ​${(props) =>
  props.status === ​"unsold"​ ? ​"lightblue"​ : stateColor(props.status)}​;
  }
 `
 
 interface​ SeatProps {
  clickHandler: (seatNumber: number) => ​void
  seatNumber: number
  status: string
 }
 
 export​ ​const​ Seat = ({
  seatNumber,
  status,
  clickHandler,
 }: SeatProps): React.ReactElement => {
 function​ changeState(): ​void​ {
  clickHandler(seatNumber)
  }
 
 return​ (
  <td>
  <ButtonSquare status=​{​status​}​ onClick=​{​changeState​}​>
 {​seatNumber + 1​}
  </ButtonSquare>
  </td>
  )
 }
 
 export​ ​default​ Seat

Adding Rails Endpoints

To make this React code work, we need to add a couple of endpoints to the Rails code: one to return ticket data and one to update shopping cart status.

The ticket status is just an update to the index method of the TicketsController to let it respond to .json requests:

 def​ ​index
  @tickets = ​if​ params[​:concert_id​]
  Ticket.​where​(​concert_id: ​params[​:concert_id​])
  .​order​(​row: :asc​, ​number: :asc​)
  .​all
  .​reject​(&​:refunded?​)
 else
  Ticket.​all
 end
  respond_to ​do​ |format|
  format.​html
  format.​json​ ​do
  render(
 json: ​@tickets.​map​(&​:to_concert_h​).​group_by​ { |t| t[​:row​] }.​values
  )
 end
 end
 end

The to_concert_h method is new and is just a simple serializer of ticket information:

 def​ ​to_concert_h
  {​id: ​id, ​row: ​row, ​number: ​number, ​status: ​status}
 end

The other is a separate controller that takes a concert ID, a seat number, and a number of tickets, and updates the status of the whole ticket batch:

 class​ ShoppingCartsController < ApplicationController
 def​ ​create
  seat_number = params[​:seatNumber​]
  seat_range = seat_number...seat_number + params[​:ticketsToBuyCount​]
  tickets = Ticket.​where​(
 concert_id: ​params[​:concertId​],
 row: ​params[​:row​],
 number: ​seat_range
  ).​all
  tickets.​update​(
 status: ​params[​:status​],
 user: ​params[status] == ​"held"​ ? current_user.​id​ : ​nil
  )
  render(​json: ​tickets.​map​(&​:to_concert_h​))
 end
 end

And with that, the React code should work. You can verify this by opening the same concert page in two different browser windows. Click on one to purchase some tickets, and in the fullness of time, the other will update and display those changes.

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

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