Skip to content

Latest commit

 

History

History
1029 lines (702 loc) · 28 KB

File metadata and controls

1029 lines (702 loc) · 28 KB

Lesson 3

multi view event planning

workbook 3 - plan a party

Agenda:

  • concepts: Don't repeat yourself - how to keep our code organized
    • using React Component for reusability
  • step0: details form tab
  • step1: the shopping list
  • step2: the invitation list
  • intermission: exercises and refactoring discussion
  • step3: the refactor!

git cheat sheet

git cheat sheet

installation:

$ cd ~/code/react-course
$ git clone https://github.com/nikfrank/react-course-workbook-3
$ cd react-course-workbook-3
$ yarn
$ npm start

NOTICE

This lesson continues learning about core React concepts and application organization.

If you've completed Lesson 2 (work for Snoop),

you'll also be ready for Lesson 4 (bitcoin charts), where API calls are introduced.

Some students will want to learn that right away, so feel free!


Most of the value of this workbook is from step3 on. To maximize learning / time, skip ahead!

END of NOTICE


core concepts:

Don't Repeat Yourself (DRY)

imagine we have an app where the user is counting up two different counters

import React, { Component } from 'react';

class TwoCounters extends Component {
  state = { count0: 0, count1: 0 }

  incCounter0 = ()=>
    this.setState({ count0: this.state.count0 + 1 })

  incCounter1 = ()=>
    this.setState({ count1: this.state.count1 + 1 })

  resetCounter0 = ()=> this.setState({ count0: 0 })
  resetCounter1 = ()=> this.setState({ count1: 0 })

  render(){
    const { count0, count1 } = this.state;

    return (
      <div>
        <div className='counter'>
          <span>{count0}</span>
          <button onClick={this.incCounter0}>++</button>
          <button onClick={this.resetCounter0}>reset</button>
        </div>
        
        <div className='counter'>
          <span>{count1}</span>
          <button onClick={this.incCounter1}>++</button>
          <button onClick={this.resetCounter1}>reset</button>
        </div>
      </div>
    );
  }
}

(you can type this into your app if you want to see it running)

You might notice that we're repeating ourself in our render

we have the same div.counter twice! just the number on the name of the handlers is different (counter0 / counter1).

so what you should be asking is: Why Everything Twice? (WET being the opposite of DRY)

The fundamental value of React as a framework is that we want to write this Counter once and use it twice (or three hundred times even)

What we'll do is make a Counter Component, refactoring all of our current logic into it.

Counter needs props: count for its current value, and an onChange function prop to call when we want to change it

./src/Counter.js

import React, { Component } from 'react';

class Counter extends Component {
  render(){
    const { count, onChange } = this.props;

    return (
      <div className='counter'>
        <span>{count}</span>
        <button onClick={()=> onChange(count+1)}>++</button>
        <button onClick={()=> onChange(0)}>reset</button>
      </div>
    );
  }
}

class TwoCounters extends Component {
  state = { count0: 0, count1: 0 }

  changeCount0 = value=> this.setState({ count0: value })
  changeCount1 = value=> this.setState({ count1: value })

  render(){
    const { count0, count1 } = this.state;
    
    return (
      <div>
        <Counter count={count0} onChange={this.changeCount0} />
        <Counter count={count1} onChange={this.changeCount1} />
      </div>
    );
  }
}

so now it's a lot easier to do new things! - let's say we want a double button

import React, { Component } from 'react';

class Counter extends Component {
  render(){
    const { count, onChange } = this.props;

    return (
      <div className='counter'>
        <span>{count}</span>
        <button onClick={()=> onChange(count+1)}>++</button>
        <button onClick={()=> onChange(0)}>reset</button>
        <button onClick={()=> onChange(count*2)}>double</button>
      </div>
    );
  }
}

that was easy! Now we have that double button everywhere, and only had to code it once

so that's the basics of props & Components, using them to dry out [refactor] our code.

It may be valuable at this time to take a thorough read through React's docs on Components and Props. We are very lucky that the React team writes such valuable docs.


now the actual lesson


I've built here for you a tabs behaviour to start with - nothing special

we're going to fill in the tabs' content (a details form, shopping list, an invites list)

then we're going to split each tab into a Component, and use props to send data to the component and function calls back up

this is going to be our first refactor!


step0: details form tab

$ git checkout step0

promo details is a form that updates a few values in the state

//...

state = {
  //...
  name: '',
  imgSrc: '',
  eventType: '',
}

//...

setName = ({ target: { value } })=> this.setState({ name: value })
setImgSrc = ({ target: { value } })=> this.setState({ imgSrc: value })
setEventType = ({ target: { value } })=> this.setState({ eventType: value })

//...

render(){
  return (
  //...
    <div className='promo-tab'>
      <label htmlFor='name'>Name</label>
      <input id='name' value={name} onChange={this.setName}/>

      <label htmlFor='imgSrc'>Picture url</label>
      <input id='imgSrc' value={imgSrc} onChange={this.setImgSrc}/>

      <label htmlFor='eventType'>Event Type</label>
      <input id='eventType' value={eventType} onChange={this.setEventType}/>      
    </div>
  //...
  );
}

same as last time, pretty easy! Hopefully we're getting used to the controlled input pattern already.

Later in this lesson, we'll render the image whose url the user put in.

step1: the shopping list

$ git checkout step1

if we want to keep all that work from step0 git cheat sheet

This is basically a todo list (which if you've ever done any front end application development before you'll be familiar with)

which is to say, it's made up of some very standard parts:

  • initial state (empty array)
  • add new item (items are some object with a standard form)
  • edit existing item (update handler functions)
  • remove item (another instance method to update the list)

if you're familiar with CRUD (Create, Read, Update, Delete) as a pattern for APIs, you'll see the connection here

  • addShoppingItem = ()=> /* ... */ = Create
  • { this.state.shoppingList.map( item => (/* ... */) ) } is our Read (in JSX in render)
  • updateListValue = ()=> /* ... */ = Update
  • removeShoppingItem = ()=> /* ... */ = Delete

now we just need to fill these in!

initial state

adding shoppingList to our state

state = {
  //...
  shoppingList: [],
}

should do for now,

add new item
  addShoppingItem = ()=>
    this.setState(state => ({
      shoppingList: state.shoppingList.concat({ item: '', quantity: 0 })
    }) )

here we're using setState's update function param instead of just passing an object. This is the original use case that the "updater function" was made for (trying to do this without an updater function can cause race conditions)

the schema for our shopping list item will be

{ item: String, quantity: Number }

which is why we're initializing new items to

{ item: '', quantity: 0 }

.item could be better named .name, but name is actually a pretty bad name in general. In real world examples, items in lists will usually have an .id and a .displayName... for now, we can make do with this.

  • array concat is an easy way to make a new array with all the old elements and more on the end

let's also make a button to call this function with

//...
(currentTab === 1) ? (
  <div className='shopping-list'>
    <button onClick={this.addShoppingItem}>+</button>
  </div>
)
//...

and then we'll want to render the new items!

//...
render(){
  const { shoppingList } = this.state;

  //... (put this <ul> inside the shopping-list div as well!)
  <ul>
    {
      shoppingList.map( ({ item, quantity }, sli)=> (
        <li key={sli}>
          <label htmlFor={`${sli}-sli-item`}>item</label>
          <input id={`${sli}-sli-item`} value={item}/>

          <label htmlFor={`${sli}-sli-quantity`}>quantity</label>
          <input id={`${sli}-sli-quantity`} value={quantity} type='number'/>
        </li>
      ) )
    }
  </ul>
  //...
}
//...
update existing items

you'll see if you try, our current list inputs don't let us update the values!

we can make an item, but then it's stuck forever as { item: '' quantity: 0 }.

hmmm. not a very good BBQ yet.

let's make some functions for updating the values then already

//...
  setListItem = ({ target: { value, id } })=> {
    const index = parseInt(id, 10);
    
    this.setState(state => ({
      shoppingList: state.shoppingList.map( (listItem, sli)=>
        (sli !== index) ? listItem : {...listItem, item: value}
      )
    }) )
  }

  setListQuantity = ({ target: { value, id } })=> {
    const index = parseInt(id, 10);
    
    this.setState(state => ({
      shoppingList: state.shoppingList.map( (listItem, sli)=>
        (sli !== index) ? listItem : {...listItem, quantity: value}
      )
    }) )
  }
//...

let's walk through how this update works

we get the value and id props from our targeted input virtual-dom element

we'll set the item/quantity in the state to value, and we'll use the id to figure out which shoppingList element we should be updating

const index = parseInt(id, 10);

takes the number off the front of the id prop and reads it in base 10 (ie like a human)

we're using the setState updater function again, and this time we're computing a new array to put in place of the old state.shoppingList

state.shoppingList.map( (listItem, sli)=>
  (sli !== index) ? listItem : {...listItem, quantity: value}
)

mapping over the items will usually update all the items... here we only want to update one of them!

luckily, we kept track of the index of the item we want to update, so we can use a ternary to ignore the other items... reading the code:

when the index (sli := 'shopping list index') isn't the same as the index we computed from the id prop from our input target from our onChange event, the map function returns the old item in place

when it is the same, we should put a new item there exactly the same as the old one, but with quantity overwritten withe value we got as a param.

the same thing is happening on the other function, just withe other field.

we'll want to trigger these functions from our list item inputs too!

<input id={`${sli}-sli-item`}
       value={item}
       onChange={this.setListItem}/>

etc.

removing items

if you got the pattern yet, you can guess what we're going to do

  • write a function on our Component to do what the user expects (remove the item)
  • bind that function in our render (we should make a new button here with an X on it)

in our <li>

<button value={sli} onClick={this.removeShoppingItem}>
  X
</button>

so then we need a this.removeShoppingItem function

the value prop we'll use in the this.removeShoppingItem function to know which item to remove

removeShoppingItem = ({ target: { value } })=>
  this.setState(state => ({
    shoppingList: state.shoppingList.slice(0, value*1)
                       .concat( state.shoppingList.slice( value*1 +1 ) )
  }) )

( the value*1 is how to cast value to a Number... button values get casted to string by the DOM )

all the slice calls do here is compute the part of the array before and after the element we're removing. Then concat puts those two together, which gives us what we want.

so now you've written a CRUD list app.

step2: the invitation list

$ git checkout step2

if we want to keep all that work from step1 git cheat sheet

this is actually a simpler version of our shopping list

let's start with what we're adding to the state

state = {
  //...
  invites: [],
  newInvite: '',
}

we'll want an input box for the name of the next person we're going to invite

setNewInvite = ({ target: { value } })=> this.setState({ newInvite: value })

//... (make sure this goes in the invitations tab)

<label htmlFor='new-invite'>New invite - To</label>
<input value={newInvite} onChange={this.setNewInvite} id='new-invite'/>

and a this.addInvite method

addInvite = ()=>
  this.setState(state => ({
    invites: state.invites.concat({ to: state.newInvite, status: '' }),
  }) )

but we'll want the input box cleared when we add the invitation

addInvite = ()=>
  this.setState(state => ({
    invites: state.invites.concat({ to: state.newInvite, status: '' }),
    newInvite: '',
  }) )

//...

<button onClick={this.addInvite}>+</button>

of course we'll need a way to update the rsvp status for an invite

rsvp = ({ target: { value, id } })=> {
  const index = parseInt(id, 10);

  this.setState(state => ({
    invites: state.invites.map( (invite, ii)=>
      (index !== ii) ? invite : ({
        ...invite, status: value,
      }) ),
  }) );
}

using the same id -> parseInt trick we used before to update the correct invite

so let's put it together in the render (remembering to make the id work with our trick)

<ul>
  {
    invites.map( ({ to, status }, ii)=> (
      <li key={ii}>
        To: {to}
        <select value={status} id={`${ii}-rsvp`}
                onChange={this.rsvp}>
          <option value=''>No RSVP</option>
          <option value='confirmed'>Confirmed</option>
          <option value='Maybe'>Maybe</option>
        </select>
      </li>
    ))
  }
</ul>

not so bad - to write a quick feature like a list we can add to and edit all we needed was

  • an add function (4 lines)
  • an update function for the new item (1 line)
  • an update function for an existing item (6 lines)
  • to render the list (12 lines of JSX)

most of the time, writing a feature in React can be boiled down to render plus a few short functions

even in the next workbook when we start working with async / network behaviour (like asking an API for the data for our list) we'll still just be writing a render plus a few short functions.


intermission: exercises and refactoring discussion


at this point we've written all the features we'll write in this workbook and so it is a good time to pause and write some CSS.

The main drive of this course is to learn React, and so I won't walk you through the CSS - instead I will leave a list of links here of very useful rules to learn (which if you checkout the next step's branch you'll see me using!)

in general, I see my skills with CSS more like baking and less like cooking (that is to say, I work well with talented designers, happy to follow their recipe)

however, sometimes we don't get talented designers on our team, and so should develop some sense of style no?

also, if you want to flex your skills from workbook-2, try putting validation on some of our inputs:

  • enforce uniqueness on invite names
  • enforce non-negative values for quantities on our shopping list (-1 would mean we need to sell? what kind of BBQ is this???)
  • use a regex to enforce a valid url on the imgSrc (and make sure it's an image file extension)
  • render the imgSrc that the user puts in (the solution to this is later in the lesson still!)

step3: the refactor!

"Explain yourself as simply as possible, not one bit simpler"
    - Einstein
$ git checkout step3

if we want to keep all that work from step2 git cheat sheet

This is the most important part of this workbook. Take your time withe material, it's more philosophical than what we've been doing so far.

Refactor means no new features. We're going to take our App.js, which has become very long (163 lines is tooooooo much. ideally we have no file > 100 LOC) and of many concerns (App houses the handler functions for three entirely separate views) - and split it apart (to get SOC: Separation Of Concerns)

the only new code we'll write is to connect our App.js (which is our view and state component) to our feature components (<Promo/> <ShoppingList/> and <Invites/>)

let's start by making a file for each of our new Components and putting the boilerplate React Component code in there.

$ touch ./src/Promo.js
$ touch ./src/Promo.css
$ touch ./src/ShoppingList.js
$ touch ./src/ShoppingList.css
$ touch ./src/Invites.js
$ touch ./src/Invites.css
import React, { Component } from 'react';
import './Promo.css';

export default Promo extends Component {
  render(){
    return (
      <div>
        Put Promo stuff here
      </div>
    );
  }
};

(similarly for the other two)

Now our goal is to make our App.js AS SIMPLE AS POSSIBLE AND NO SIMPLER

The way we'll accomplish this is by moving the logic (handler functions) and JSX specific to each tab into that tab's Component, then using each Component's props to send values and onChange handlers from App's state to the Component.

our current list of handlers is very long and have signatures specific to the rendered input elements

  setName = ({ target: { value } })=> //...
  setImgSrc = ({ target: { value } })=> //...
  setEventType = ({ target: { value } })=> //...

  addShoppingItem = ()=> //...
  removeShoppingItem = ({ target: { value } })=> //...
  setListItem = ({ target: { value, id } })=> //...
  setListQuantity = ({ target: { value, id } })=> //...
  
  setNewInvite = ({ target: { value } })=> //...
  addInvite = ()=> //...

  rsvp = ({ target: { value, id } })=> //...

it would be simpler [preferable] if we had something like

  setName = name=> this.setState({ name })
  setImgSrc = imgSrc=> this.setState({ imgSrc })
  setEventType = eventType=> this.setState({ eventType })

  onChangeShoppingList = shoppingList => this.setState({ shoppingList })
  
  onChangeInvites = invites => this.setState({ invites })

to make it so, we'll need our Components to call these new handlers back withe new values already computed

  • addShoppingItem
  • removeShoppingItem
  • setListItem
  • setListQuantity

-> onChangeShoppingList

and

  • setNewInvite
  • addInvite
  • rsvp

-> onChangeInvites

as well as signature changes for

  • setName
  • setEventType
  • setImgSrc

also, it'd be better if we could move the imgSrc validation into the Promo, so it never pollutes our Event state.


Promo

so let's render our new Promo Component into App so we can start moving code around:

import Promo from './Promo';

//...
(currentTab === 0) ? (
  <Promo setName={this.setName}
         name={name}
         setImgSrc={this.setImgSrc}
         imgSrc={imgSrc}
         setEventType={this.setEventType}
         eventType={eventType}/>
)

and so we can cut and paste

<div className='promo-tab form-field'>
  <label htmlFor='name'>Name</label>
  <input id='name' value={name} onChange={this.setName}/>

  <label htmlFor='imgSrc'>Picture url</label>
  <input id='imgSrc' value={imgSrc} onChange={this.setImgSrc}/>

  <label htmlFor='eventType'>Event Type</label>
  <input id='eventType' value={eventType} onChange={this.setEventType}/>

  {imgSrcValid && (<img src={imgSrc} alt='event'/>)}
</div>

from ./src/App.js to ./src/Promo.js's render function

now, inside Promo, we have access to this.props.setName and all the other props we've passed in from App

React will update-render our Promo Component whenever his .props change, just like he's been update-rendering our App Component every time his .state changed.

let's move our input handling logic to inside Promo

import React, { Component } from 'react';

const imageUrlRegex = /(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))/i;

export default class Promo extends Component {
  state = { imgSrcValid: !!imageUrlRegex.exec(this.props.imgSrc) }


  // our state initialization here can't just assume that the ```imgSrc```
  // is invalid on init like we did before

  // if we go to another tab and then come back to ```Promo```,
  //  we might have a valid imgSrc value in ```App```'s ```state```
  // that we receive in ```Promo```'s ```props```,
  // so we have to compute the validity in our state initialization.

  setImgSrc = ({ target: { value } })=> {
    this.setState({ imgSrcValid: !!imageUrlRegex.exec(value) });
    this.props.setImgSrc( value );
  }
  
  setEventType = ({ target: { value } })=> this.props.setEventType( value )
  setName = ({ target: { value } })=> this.props.setName( value )

  render(){
    const { eventType, name, imgSrc } = this.props;
    const { imgSrcValid } = this.state;
   
    //...
  }
}

we can update the relevant handlers in App to the simpler signatures now.

we can also remove .imgSrcValid from App's .state now that that is being kept track of on our Promo Component's .state


Shopping List

so let's render our new ShoppingList Component into App so we can start moving code around:

import ShoppingList from './ShoppingList';

//...
) : (currentTab === 1) ? (
  <ShoppingList shoppingList={shoppingList}
                onChange={this.onChangeShoppingList}/>
) : //...

and so we can move the JSX from our second tab into ./src/ShoppingList.js

//...
  render(){
    const { shoppingList } = this.props;
    
    return (
      <div className='shopping-list form-field'>
        <button onClick={this.addShoppingItem} className='add'>+</button>
        <ul>
          {
            shoppingList.map( ({ item, quantity }, sli)=> (
              <li key={sli}>
                <label htmlFor={`${sli}-sli-item`}>item</label>
                <input id={`${sli}-sli-item`}
                       value={item}
                       onChange={this.setListItem}/>

                <label htmlFor={`${sli}-sli-quantity`}>quantity</label>
                <input id={`${sli}-sli-quantity`}
                       value={quantity}
                       type='number'
                       min={0}
                       onChange={this.setListQuantity}/>

                <button value={sli}
                        onClick={this.removeShoppingItem}
                        className='remove'>
                  X
                </button>
              </li>
            ) )
          }
        </ul>
      </div>
    );
  }

and so we'll move our handlers for the shopping list into the Component, and refactor them to call this.props.onChange instead of using this.setState as they did until now

//...
  addShoppingItem = ()=>
    this.props.onChange(
      this.props.shoppingList.concat({ item: '', quantity: 0 }) )

  removeShoppingItem = ({ target: { value } })=>
    this.props.onChange(
      this.props.shoppingList.slice(0, value*1)
          .concat( this.props.shoppingList.slice( value*1 +1 ) ) )
  
  setListItem = ({ target: { value, id } })=> {
    const index = parseInt(id, 10);
    
    this.props.onChange(
      this.props.shoppingList.map( (listItem, sli)=>
        (sli !== index) ? listItem : {...listItem, item: value}
      )
    )
  }

  setListQuantity = ({ target: { value, id } })=> {
    const index = parseInt(id, 10);
    
    this.props.onChange(
      this.props.shoppingList.map( (listItem, sli)=>
        (sli !== index) ? listItem : {...listItem, quantity: value}
      )
    )
  }
//...

and at last we can update the signatures of the App level handlers to our simpler format.


Invites

so let's render our new Invites Component into App so we can start moving code around:

import Invites from './Invites';

//...
) : (currentTab === 2) && (
  <Invites invites={invites}
           onChange={this.onChangeInvites}/>
)

and so we can move the JSX from our second tab into ./src/Invites.js

  render(){
    const { invites } = this.props;
    const { newInvite } = this.state;
    
    return (
      <div className='invitations form-field'>
        <label htmlFor='new-invite'>New invite - To</label>
        <input value={newInvite} onChange={this.setNewInvite} id='new-invite'/>
        <button onClick={this.addInvite}
                disabled={!newInvite}
                className='add'>
          +
        </button>
        
        <ul>
          {
            invites.map( ({ to, status }, ii)=> (
              <li key={ii}>
                To: {to}
                <select value={status} id={`${ii}-rsvp`}
                        onChange={this.rsvp}>
                  <option value=''>No RSVP</option>
                  <option value='confirmed'>Confirmed</option>
                  <option value='Maybe'>Maybe</option>
                </select>
              </li>
            ))
          }
        </ul>
      </div>
    );
  }

and so we'll move our handlers for the invites into the Component, and refactor them to call this.props.onChange instead of using this.setState as they did until now

  state = { newInvite: '' }

  setNewInvite = ({ target: { value } })=> this.setState({ newInvite: value })
  
  addInvite = ()=>
    this.props.invites.find(({ to })=> to === this.state.newInvite) || (
      this.setState(state => ({ newInvite: '' }) ),
      this.props.onChange(
        this.props.invites.concat({ to: this.state.newInvite, status: '' })
      )
    )

  rsvp = ({ target: { value, id } })=> {
    const index = parseInt(id, 10);
    
    this.props.onChange(
      this.props.invites.map( (invite, ii)=>
        (index !== ii) ? invite : ({
          ...invite, status: value,
        })
      )
    )
  }

and finally we can update our App handlers to exactly what we wanted at the top of the step

  setName = name=> this.setState({ name })
  setImgSrc = imgSrc=> this.setState({ imgSrc })
  setEventType = eventType=> this.setState({ eventType })

  onChangeShoppingList = shoppingList => this.setState({ shoppingList })
  
  onChangeInvites = invites => this.setState({ invites })

at the end of all of that, our ./src/App.js is only 75 lines long. much better!

the CSS files are still empty for each Component... feel free to practice your styling chops while keeping the styles separated!

step4: sending out data to an API

here, we're going to send the event we're making to our API (which is entirely ficticious at this point)

(unimplemented...)


if you want to write the tabs as an exercise,

git checkout start

or do git diff start step0 to see what's been written for the tabs

back to index

prev lesson

next lesson