Skip to content
This repository has been archived by the owner on Nov 12, 2023. It is now read-only.

chesterheng/advanced-react-patterns

Repository files navigation

The Complete Guide to Advanced React Component Patterns

Table of Contents

Section 1: Introduction

Design Patterns

  • Time-tested solution to recurring design problems

Why Advanced React Patterns

  • Solve issues related to building reusable components using proven solutions
  • Development of highly cohesive components with minimal coupling
  • Better ways to share logic between components

⬆ back to top

Section 2: The Medium Clap: Real-world Component for Studying Advanced React Patterns

Why build the medium clap?

Building and styling the medium clap

  1. The default State of the Component - unclicked
  2. The Clap Count & Burst Shown
  3. The Clap Total count Show

The default State of the Component - unclicked The Clap Count & Burst Shown The Clap Total count Show

<button>
	<ClapIcon />
	<ClapCount />
	<CountTotal />
</button>

⬆ back to top

Building and styling the medium clap

const MediumClap = () => (
	<button>
		<ClapIcon />
		<ClapCount />
		<CountTotal />
	</button>
)

const ClapIcon = ({ isClicked }) => (
	<span>
		<svg> ... </svg>
	</span>
)
const ClapCount = ({ count }) => <span>+ {count}</span>
const CountTotal = ({ countTotal }) => <span>{countTotal}</span>

⬆ back to top

Handling user interactivity

const initialState = {
  count: 0,
  countTotal: 267,
  isClicked: false
}

const MediumClap = () => {
  const MAXIMUM_USER_CLAP = 50
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal, isClicked } = clapState

  const handleClapClick = () => {
    animate()
    setClapState(prevState => ({ ... }))
  }
  return (
    <button onClick={handleClapClick} >
      <ClapIcon isClicked={isClicked}/>
      <ClapCount count={count} />
      <CountTotal countTotal={countTotal} />
    </button>
  )
}

const ClapIcon = ({ isClicked }) => (
  <span>
    <svg className={`${isClicked && styles.checked}`>
    </svg>
  </span>
)
const ClapCount = ({ count }) => <span>+ {count}</span>
const CountTotal = ({ countTotal }) => <span>{countTotal}</span>

⬆ back to top

Higher order components recap

console.log timestamps in Chrome

  • Console: (...) -> Settings -> Preferences -> Console -> [x] Show timestamps

Higher-Order Components

  • Component -> HOC -> Component*
  • Component(Logic) -> HOC -> Component*(Animation +Logic)
const withClapAnimation = WrappedComponent => {
  class WithClapAnimation extends Component {
    animate = () => {
      console.log('%c Animate', 'background:yellow; color:black')
    }
    render() {
      return <WrappedComponent {...this.props} animate={this.animate}/>
    }
  }
  return WithClapAnimation
}

const MediumClap = ({ animate }) => {
  const handleClapClick = () => {
    animate()
    setClapState(prevState => ({ ... }))
  }

  return (
    <button
      className={styles.clap}
      onClick={handleClapClick}
    >
      <ClapIcon isClicked={isClicked} />
      <ClapCount count={count} />
      <CountTotal countTotal={countTotal} />
    </button>
  )
}

export default withClapAnimation(MediumClap)

⬆ back to top

Beginning to animate the clap

Animation Timeline

Animation Element Property Delay Start value (time) Stop value (time)
scaleButton #clap scale 0 1.3 (t=delay) 1 (t=duration)
triangleBurst #clap radius 0 50 (t=delay) 95 (t=duration)
circleBurst #clap radius 0 50 (t=delay) 75 (t=duration)
countAnimation #clapCount opacity 0 0 (t=delay) 1 (t=duration)
countAnimation #clapCount opacity duration / 2 1 (t=duration) 0 (t=duration + delay)
countTotalAnimation #clapCountTotal opacity (3 * duration) / 2 0 (t=delay) 1 (t=duration)
import mojs from 'mo-js'

const withClapAnimation = WrappedComponent => {
  class WithClapAnimation extends Component {
    state = {
      animationTimeline: new mojs.Timeline()
    }
    render() {
      return <WrappedComponent
        {...this.props}
        animationTimeline={this.state.animationTimeline />
    }
  }
  return WithClapAnimation
}

const MediumClap = ({ animationTimeline }) => {
  const handleClapClick = () => {
    animationTimeline.replay()
    setClapState(prevState => ({ ... }))
  }

  return (
    <button
      className={styles.clap}
      onClick={handleClapClick}
    >
      <ClapIcon isClicked={isClicked} />
      <ClapCount count={count} />
      <CountTotal countTotal={countTotal} />
    </button>
  )
}

export default withClapAnimation(MediumClap)

⬆ back to top

Creating and updating the animation timeline

Animation Timeline

Animation Element Property Delay Start value (time) Stop value (time)
scaleButton #clap scale 0 1.3 (t=delay) 1 (t=duration)
import mojs from 'mo-js'

const withClapAnimation = WrappedComponent => {
  class WithClapAnimation extends Component {
    animationTimeline = new mojs.Timeline()
    state = {
      animationTimeline: this.animationTimeline
    }

    componentDidMount() {
      const scaleButton = new mojs.Html({
        el: "#clap",
        duration: 300,
        scale: { 1.3 : 1 },
        easing: mojs.easing.ease.out
      })
      const newAnimationTimeline =
        this.animationTimeline.add([scaleButton])
      this.setState({ animationTimeline: newAnimationTimeline})
    }

    render() {
      return <WrappedComponent
        {...this.props}
        animationTimeline={this.state.animationTimeline />
    }
  }
  return WithClapAnimation
}

const MediumClap = ({ animationTimeline }) => {
  const handleClapClick = () => {
    animationTimeline.replay()
    setClapState(prevState => ({ ... }))
  }

  return (
    <button
      id="clap"
      className={styles.clap}
      onClick={handleClapClick}
    >
      <ClapIcon isClicked={isClicked} />
      <ClapCount count={count} />
      <CountTotal countTotal={countTotal} />
    </button>
  )
}

export default withClapAnimation(MediumClap)

⬆ back to top

Resolving wrong animated scale

import mojs from 'mo-js'

const withClapAnimation = WrappedComponent => {
  class WithClapAnimation extends Component {
    animationTimeline = new mojs.Timeline()
    state = {
      animationTimeline: this.animationTimeline
    }

    componentDidMount() {
      const tlDuration = 300
      const scaleButton = new mojs.Html({
        el: "#clap",
        duration: tlDuration,
        scale: { 1.3 : 1 },
        easing: mojs.easing.ease.out
      })

      // scale back to 1 before animation start
      const clap = document.getElementById('clap')
      clap.style.transform = 'scale(1,1)'

      const newAnimationTimeline =
        this.animationTimeline.add([scaleButton, countTotalAnimation])
      this.setState({ animationTimeline: newAnimationTimeline})
    }

    render() {
      return <WrappedComponent
        {...this.props}
        animationTimeline={this.state.animationTimeline />
    }
  }
  return WithClapAnimation
}

export default withClapAnimation(MediumClap)

⬆ back to top

Animating the total count

Animation Timeline

Animation Element Property Delay Start value (time) Stop value (time)
scaleButton #clap scale 0 1.3 (t=delay) 1 (t=duration)
countTotalAnimation #clapCountTotal opacity (3 * duration) / 2 0 (t=delay) 1 (t=duration)
import mojs from 'mo-js'

const withClapAnimation = WrappedComponent => {
  class WithClapAnimation extends Component {
    animationTimeline = new mojs.Timeline()
    state = {
      animationTimeline: this.animationTimeline
    }

    componentDidMount() {
      const tlDuration = 300
      const scaleButton = new mojs.Html({ ... })
      const clap = document.getElementById('clap')
      clap.style.transform = 'scale(1,1)'

      const countTotalAnimation = new mojs.Html({
        el: "#clapCountTotal",
        delay: (3 * tlDuration) / 2,
        duration: tlDuration,
        // opacity from [t=delay, 0] to [t=300, 1]
        opacity: { 0 : 1 },
        // move up y axis from [t=delay, y=0] to [t=300, y=-3]
        y: { 0 : -3 }
      })

      const newAnimationTimeline =
        this.animationTimeline.add([scaleButton, countTotalAnimation])
      this.setState({ animationTimeline: newAnimationTimeline})
    }

    render() {
      return <WrappedComponent
        {...this.props}
        animationTimeline={this.state.animationTimeline />
    }
  }
  return WithClapAnimation
}

const CountTotal = ({ countTotal }) => (
  <span id="clapCountTotal" className={styles.total}>
    {countTotal}
  </span>
)

export default withClapAnimation(MediumClap)

⬆ back to top

Animating the clap count

Animation Timeline

Animation Element Property Delay Start value (time) Stop value (time)
scaleButton #clap scale 0 1.3 (t=delay) 1 (t=duration)
countAnimation #clapCount opacity 0 0 (t=delay) 1 (t=duration)
countAnimation #clapCount opacity duration / 2 1 (t=duration) 0 (t=duration + delay)
countTotalAnimation #clapCountTotal opacity (3 * duration) / 2 0 (t=delay) 1 (t=duration)
import mojs from 'mo-js'

const withClapAnimation = WrappedComponent => {
  class WithClapAnimation extends Component {
    animationTimeline = new mojs.Timeline()
    state = {
      animationTimeline: this.animationTimeline
    }

    componentDidMount() {
      const tlDuration = 300
      const scaleButton = new mojs.Html({ ... })
      const clap = document.getElementById('clap')
      clap.style.transform = 'scale(1,1)'
      const countTotalAnimation = new mojs.Html({ ... })

      const countAnimation = new mojs.Html({
        el: "#clapCount",
        duration: tlDuration,
        opacity: { 0 : 1 },
        y: { 0 : -30 }
      }).then({
        // next animation to fade out
        delay: tlDuration / 2,
        opacity: { 1 : 0 },
        y: -80
      })

      const newAnimationTimeline =
        this.animationTimeline.add([
          scaleButton,
          countTotalAnimation,
          countAnimation
        ])
      this.setState({ animationTimeline: newAnimationTimeline})
    }

    render() {
      return <WrappedComponent
        {...this.props}
        animationTimeline={this.state.animationTimeline />
    }
  }
  return WithClapAnimation
}

const ClapCount = ({ count }) => (
  <span id="clapCount" className={styles.count}>
    + {count}
  </span>
)

export default withClapAnimation(MediumClap)

⬆ back to top

Creating animated bursts!

Bezier Generator

Animation Timeline

Animation Element Property Delay Start value (time) Stop value (time)
scaleButton #clap scale 0 1.3 (t=delay) 1 (t=duration)
triangleBurst #clap radius 0 50 (t=delay) 95 (t=duration)
circleBurst #clap radius 0 50 (t=delay) 75 (t=duration)
countAnimation #clapCount opacity 0 0 (t=delay) 1 (t=duration)
countAnimation #clapCount opacity duration / 2 1 (t=duration) 0 (t=duration + delay)
countTotalAnimation #clapCountTotal opacity (3 * duration) / 2 0 (t=delay) 1 (t=duration)
const withClapAnimation = WrappedComponent => {
  class WithClapAnimation extends Component {
    animationTimeline = new mojs.Timeline()
    state = {
      animationTimeline: this.animationTimeline
    }

    componentDidMount() {
      const tlDuration = 300
      const scaleButton = new mojs.Html({ ... })
      const countTotalAnimation = new mojs.Html({ ... })
      const countAnimation = new mojs.Html({ ... })
      const clap = document.getElementById('clap')
      clap.style.transform = 'scale(1,1)'

      // particle effect burst
      const triangleBurst = new mojs.Burst({
        parent: "#clap",
        // radius from [t=0, 50] to [t=300, 95]
        radius: { 50 : 95 },
        count: 5,
        angle: 30,
        children: {
          // default is triangle
          shape: 'polygon',
          radius: { 6 : 0 },
          stroke: 'rgba(211,54,0,0.5)',
          strokeWidth: 2,
          // angle of each particle
          angle: 210,
          speed: 0.2,
          delay: 30,
          easing: mojs.easing.bezier(0.1, 1, 0.3, 1),
          duration: tlDuration,
        }
      })

      const circleBurst = new mojs.Burst({
        parent: '#clap',
        radius: { 50: 75 },
        angle: 25,
        duration: tlDuration,
        children: {
          shape: 'circle',
          fill: 'rgba(149,165,166,0.5)',
          delay: 30,
          speed: 0.2,
          radius: { 3 : 0 },
          easing: mojs.easing.bezier(0.1, 1, 0.3, 1)
        }
      })

      // update timeline with scaleButton animation
      const newAnimationTimeline =
        this.animationTimeline.add([
          scaleButton,
          countTotalAnimation,
          countAnimation,
          triangleBurst,
          circleBurst
        ])
      this.setState({ animationTimeline: newAnimationTimeline})
    }

    render() {
      return <WrappedComponent
        {...this.props}
        animationTimeline={this.state.animationTimeline} />
    }
  }
  return WithClapAnimation
}

⬆ back to top

Section 3: Custom Hooks: The first Foundational Pattern

New to hooks?

Basic Hooks

Additional Hooks

How do lifecycle methods correspond to Hooks?

  • constructor: Function components don’t need a constructor. You can initialize the state in the useState call. If computing the initial state is expensive, you can pass a function to useState.
  • getDerivedStateFromProps: Schedule an update while rendering instead.
  • shouldComponentUpdate: See React.memo below.
  • render: This is the function component body itself.
  • componentDidMount, componentDidUpdate, componentWillUnmount: The useEffect Hook can express all combinations of these (including less common cases).
  • getSnapshotBeforeUpdate, componentDidCatch and getDerivedStateFromError: There are no Hook equivalents for these methods yet, but they will be added soon.

⬆ back to top

Custom Hooks are a mechanism to reuse stateful logic

// name must start with "use"!
const useAdvancedPatterns = () => {
	// state and effects isolated here
}

// Must be called from a React fn component/other custom hook
useAdvancedPatterns()

Open-source examples

Pros Cons
Single Responsibility Modules Bring your own UI
Reduced complexity

Pros

  • Single Responsibility Modules
    • As seen in react-use, custom hooks are a simple way to share single responsibility modules within React apps.
  • Reduced complexity
    • Custom hooks are a good way to reduce complexity in your component library. Focus on logic and let the user bring their own UI e.g. React Table.

Cons

  • Bring your own UI
    • Historically, most users expect open-source solutions like React Table to include Table UI elements and props to customize its feel and functionality. Providing only custom hooks may throw off a few users. They may find it harder to compose hooks while providing their own UI.

⬆ back to top

Building an animation custom hook

Building Your Own Hooks

  • MediumClap (Logic) -> invoke hook -> useClapAnimation (Animation)
  • MediumClap (Logic) <- returns a value <- useClapAnimation (Animation)
const initialState = {
  count: 0,
  countTotal: 267,
  isClicked: false
}

// Custom Hook for animaton
const useClapAnimation = () => {

  // Do not write useState(new mojs.Timeline())
  // if not every single time useClapAnimation is called
  // new mojs.Timeline() is involved
  const [animationTimeline, setAnimationTimeline] = useState(() => new mojs.Timeline())

  useEffect(() => {
    const tlDuration = 300
    const scaleButton = new mojs.Html({ ... })
    const countTotalAnimation = new mojs.Html({ ... })
    const countAnimation = new mojs.Html({ ... })
    const clap = document.getElementById('clap')
    clap.style.transform = 'scale(1,1)'
    const triangleBurst = new mojs.Burst({ ... })
    const circleBurst = new mojs.Burst({ ... })
    const newAnimationTimeline = animationTimeline.add(
      [
        scaleButton,
        countTotalAnimation,
        countAnimation,
        triangleBurst,
        circleBurst
      ])
    setAnimationTimeline(newAnimationTimeline)
  }, [])

  return animationTimeline;
}

const MediumClap = () => {
  const MAXIMUM_USER_CLAP = 50
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal, isClicked } = clapState

  const animationTimeline = useClapAnimation()

  const handleClapClick = () => {
    animationTimeline.replay()
    setClapState(prevState => ({ ... }))
  }

  return (
    <button
      id="clap"
      className={styles.clap}
      onClick={handleClapClick}
    >
      <ClapIcon isClicked={isClicked} />
      <ClapCount count={count} />
      <CountTotal countTotal={countTotal} />
    </button>
  )
}

const ClapIcon = ({ isClicked }) => ( ... )
const ClapCount = ({ count }) => ( ... )
const CountTotal = ({ countTotal }) => ( ... )

export default MediumClap

⬆ back to top

Custom hooks and refs

const initialState = {
  count: 0,
  countTotal: 267,
  isClicked: false
}

// Custom Hook for animaton
const useClapAnimation = ({
  clapEl,
  clapCountEl,
  clapCountTotalEl
}) => {

  // Do not write useState(new mojs.Timeline())
  // if not every single time useClapAnimation is called
  // new mojs.Timeline() is involved
  const [animationTimeline, setAnimationTimeline] = useState(() => new mojs.Timeline())

  useEffect(() => {
    const tlDuration = 300
    const scaleButton = new mojs.Html({
      el: clapEl,
      duration: tlDuration,
      // scale from [t=0, 1.3] to [t=300, 1]
      scale: { 1.3 : 1 },
      easing: mojs.easing.ease.out
    })

    const countTotalAnimation = new mojs.Html({
      el: clapCountTotalEl,
      delay: (3 * tlDuration) / 2,
      duration: tlDuration,
      // opacity from [t=delay, 0] to [t=300, 1]
      opacity: { 0 : 1 },
      // move up y axis from [t=delay, y=0] to [t=300, y=-3]
      y: { 0 : -3 }
    })

    const countAnimation = new mojs.Html({
      el: clapCountEl,
      duration: tlDuration,
      opacity: { 0 : 1 },
      y: { 0 : -30 }
    }).then({
      // next animation to fade out
      delay: tlDuration / 2,
      opacity: { 1 : 0 },
      y: -80
    })

    // scale back to 1 before animation start
    if(typeof clapEl === 'string') {
      const clap = document.getElementById('clap')
      clap.style.transform = 'scale(1,1)'
    } else {
      clapEl.style.transform = 'scale(1,1)'
    }

    // particle effect burst
    const triangleBurst = new mojs.Burst({
      parent: clapEl,
      // radius from [t=0, 50] to [t=300, 95]
      radius: { 50 : 95 },
      count: 5,
      angle: 30,
      children: {
        // default is triangle
        shape: 'polygon',
        radius: { 6 : 0 },
        stroke: 'rgba(211,54,0,0.5)',
        strokeWidth: 2,
        // angle of each particle
        angle: 210,
        speed: 0.2,
        delay: 30,
        easing: mojs.easing.bezier(0.1, 1, 0.3, 1),
        duration: tlDuration,
      }
    })

    const circleBurst = new mojs.Burst({
      parent: clapEl,
      radius: { 50: 75 },
      angle: 25,
      duration: tlDuration,
      children: {
        shape: 'circle',
        fill: 'rgba(149,165,166,0.5)',
        delay: 30,
        speed: 0.2,
        radius: { 3 : 0 },
        easing: mojs.easing.bezier(0.1, 1, 0.3, 1)
      }
    })

    const newAnimationTimeline = animationTimeline.add(
      [
        scaleButton,
        countTotalAnimation,
        countAnimation,
        triangleBurst,
        circleBurst
      ])
    setAnimationTimeline(newAnimationTimeline)
  }, [])

  return animationTimeline;
}

const MediumClap = () => {
  const MAXIMUM_USER_CLAP = 50
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal, isClicked } = clapState

  const [{ clapRef, clapCountRef, clapCountTotalRef }, setRefState] = useState({})
  const setRef = useCallback(node => {
    setRefState(prevRefState => ({
      ...prevRefState,
      [node.dataset.refkey]: node
    }))
  }, [])

  const animationTimeline = useClapAnimation({
    clapEl: clapRef,
    clapCountEl: clapCountRef,
    clapCountTotalEl: clapCountTotalRef
  })

  const handleClapClick = () => {
    animationTimeline.replay()
    setClapState(prevState => ({ ... }))
  }

  return (
    <button
      ref={setRef}
      data-refkey="clapRef"
      className={styles.clap}
      onClick={handleClapClick}
    >
      <ClapIcon isClicked={isClicked} />
      <ClapCount count={count} setRef={setRef} />
      <CountTotal countTotal={countTotal} setRef={setRef} />
    </button>
  )
}

const ClapIcon = ({ isClicked }) => ( ... )

const ClapCount = ({ count, setRef }) => (
  <span
    ref={setRef}
    data-refkey="clapCountRef"
    className={styles.count}
  >
    + {count}
  </span>
)

const CountTotal = ({ countTotal, setRef }) => (
  <span
    ref={setRef}
    data-refkey="clapCountTotalRef"
    className={styles.total}
  >
    {countTotal}
  </span>
)

export default MediumClap

⬆ back to top

When is my hook invoked?

const initialState = { ... }

// Custom Hook for animaton
const useClapAnimation = ({
  clapEl,
  clapCountEl,
  clapCountTotalEl
}) => {

  // Do not write useState(new mojs.Timeline())
  // if not every single time useClapAnimation is called
  // new mojs.Timeline() is involved
  const [animationTimeline, setAnimationTimeline] = useState(() => new mojs.Timeline())

  useLayoutEffect(() => {
    if(!clapEl || !clapCountEl || !clapCountTotalEl) return

    const tlDuration = 300
    const scaleButton = new mojs.Html({ ... })
    const countTotalAnimation = new mojs.Html({ ... })
    const countAnimation = new mojs.Html({ ... })

    // scale back to 1 before animation start
    if(typeof clapEl === 'string') {
      const clap = document.getElementById('clap')
      clap.style.transform = 'scale(1,1)'
    } else {
      clapEl.style.transform = 'scale(1,1)'
    }

    // particle effect burst
    const triangleBurst = new mojs.Burst({ ... })
    const circleBurst = new mojs.Burst({ ... })

    const newAnimationTimeline = animationTimeline.add(
      [
        scaleButton,
        countTotalAnimation,
        countAnimation,
        triangleBurst,
        circleBurst
      ])
    setAnimationTimeline(newAnimationTimeline)
  }, [clapEl, clapCountEl, clapCountTotalEl])

  return animationTimeline;
}

const MediumClap = () => { ... }
const ClapIcon = ({ isClicked }) => ( ... )
const ClapCount = ({ count, setRef }) => ( ... )
const CountTotal = ({ countTotal, setRef }) => ( ... )
export default MediumClap

⬆ back to top

Section 4: The Compound Components Pattern

The pattern refers to an interesting way to communicate the relationship between UI components and share implicit state by leveraging an explicit parent-child relationship

Parent Component: MediumClap

  • Child Component: Icon
  • Child Component: Total
  • Child Component: Count
// The parent component handles the UI state values and updates. State values are communicated from parent to child
// Typically with the Context API
<MediumClap>
  <MediumClap.Icon />
  <MediumClap.Total />
  <MediumClap.Count />
</MediumClap>

// Adding the child components to the instance of the Parent component is completely optional. The following is equally valid
<MediumClap>
  <ClapIcon />
  <ClapCountTotal />
  <ClapCount />
</MediumClap>

Open-source examples

Pros Cons
Flexible Markup Structure
Reduced complexity
Separation of Concerns

Pros

  • Flexible Markup Structure
    • Users can rearrange the child components in whatever way they seem fit. e.g. having an accordion header at the bottom as opposed to the top.
  • Reduced Complexity
    • As opposed to jamming all props in one giant parent component and drilling those down to child UI components, child props go to their respective child components.
  • Separation of Concerns
    • Having all UI state logic in the Parent component and communicating that internally to all child components makes for a clear division of responsibility.

⬆ back to top

Why compound components?

  • Customizability: can change child component props
  • Understandable API: user can see all child components
  • Props Overload
<MediumClap
  clapProps={}
  countTotalProps={}
  clapIconProps={}
/>

<MediumClap>
  <ClapIcon />
  <ClapCountTotal />
  <ClapCount />
</MediumClap>

⬆ back to top

How to implement the pattern

  • Identify parent and all its children
  • Create a context object in parent
  • All children will hook up to context object
  • All children can receive props from context object

Parent Component: MediumClap - use a Provider to pass the current value to the tree below

  • Child Component: ClapIcon - get prop from context object
  • Child Component: ClapCountTotal - get prop from context object
  • Child Component: ClapCount - get prop from context object
  1. Create a context object to pass values into the tree of child components in parent component
  2. Use a Provider to pass the current value to the tree below
  3. Returns a memoized state
  4. Accepts a value prop to be passed to consuming components that are descendants of this Provider
  5. Use the special children prop to pass children elements directly into Parent component
  6. Get prop from context object instead from parent prop
// 1. Create a context object to pass values into the tree of child components in parent component
const MediumClapContext = createContext()

// 2. Use a Provider to pass the current value to the tree below
const { Provider } = MediumClapContext

// 5. Use the special children prop to pass children elements directly into Parent component
const MediumClap = ({ children }) => {

  // 3. Returns a memoized state.
  const memoizedValue = useMemo( ... )

  return (
    // 4. Accepts a value prop to be passed to consuming components that are descendants of this Provider
    // 5. Use the special children prop to pass children elements directly into Parent component
    <Provider value={memoizedValue}>
      <button>
        {children}
      </button>
    </Provider>
  )
}

// 6. Get prop from context object instead from parent prop
const ClapIcon = () => {
  const { isClicked } = useContext(MediumClapContext)
  return ( ... )
}

// 6. Get prop from context object instead from parent prop
const ClapCount = () => {
  const { count, setRef } = useContext(MediumClapContext)
  return ( ... )
}

// 6. Get prop from context object instead from parent prop
const CountTotal = () => {
  const { countTotal, setRef } = useContext(MediumClapContext)
  return ( ... )
}

// 5. Use the special children prop to pass children elements directly into Parent component
const Usage = () => {
  return (
    <MediumClap>
      <ClapIcon />
      <ClapCount />
      <ClapCountTotal />
    </MediumClap>
  )
}

⬆ back to top

Refactor to Compound components

const initialState = {
  count: 0,
  countTotal: 267,
  isClicked: false
}

const useClapAnimation = () => { ... }

// 1. MediumClapContext lets us pass a value deep into the tree of React components in MediumClap component
const MediumClapContext = createContext()

// 2. Use a Provider to pass the current value to the tree below. 
// Any component can read it, no matter how deep it is.
const { Provider } = MediumClapContext

// 5. MediumClap component don’t know their children ahead of time.
// Use the special children prop to pass children elements directly into MediumClap
const MediumClap = ({ children }) => {
  const MAXIMUM_USER_CLAP = 50
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal, isClicked } = clapState

  const [{ clapRef, clapCountRef, clapCountTotalRef }, setRefState] = useState({})
  const setRef = useCallback(node => {
    setRefState(prevRefState => ({
      ...prevRefState,
      [node.dataset.refkey]: node
    }))
  }, [])

  const animationTimeline = useClapAnimation({
    clapEl: clapRef,
    clapCountEl: clapCountRef,
    clapCountTotalEl: clapCountTotalRef
  })

  const handleClapClick = () => {
    animationTimeline.replay()
    setClapState(prevState => ({ ... }))
  }

  // 3. Returns a memoized state.
  const memoizedValue = useMemo(
    () => ({
      ...clapState,
      setRef
    }), [clapState, setRef]
  )

  return (
    // 4. Accepts a value prop to be passed to consuming components that are descendants of this Provider.
    // 5. MediumClap component don’t know their children ahead of time.
    // Use the special children prop to pass children elements directly into MediumClap
    <Provider value={memoizedValue}>
      <button
        ref={setRef}
        data-refkey="clapRef"
        className={styles.clap}
        onClick={handleClapClick}
      >
        {children}
      </button>
    </Provider>
  )
}

const ClapIcon = () => {
  // 6. Get prop from MediumClapContext instead from parent MediumClap
  const { isClicked } = useContext(MediumClapContext)
  return ( ... )
}

const ClapCount = () => {
  // 6. Get prop from MediumClapContext instead from parent MediumClap
  const { count, setRef } = useContext(MediumClapContext)
  return ( ... )
}

const CountTotal = () => {
  // 6. Get prop from MediumClapContext instead from parent MediumClap
  const { countTotal, setRef } = useContext(MediumClapContext)
  return ( ... )
}

// 5. MediumClap component don’t know their children ahead of time.
// Use the special children prop to pass children elements directly into MediumClap
const Usage = () => {
  return (
    <MediumClap>
      <ClapIcon />
      <ClapCount />
      <ClapCountTotal />
    </MediumClap>
  )
}

export default Usage

⬆ back to top

Alternative export strategy

const MediumClap = ({ children }) => { ... }
MediumClap.Icon = ClapIcon
MediumClap.Count = ClapCount
MediumClap.Total = ClapCountTotal

export default MediumClap
import MediumClap, { Icon, Count, Total } from 'medium-clap'

const Usage = () => {
  return (
    <MediumClap>
      <Icon />
      <Count />
      <Total />
    </MediumClap>
  )
}

⬆ back to top

Exposing state via a callback

const initialState = {
  count: 0,
  countTotal: 267,
  isClicked: false
}

const MediumClap = ({ children, onClap }) => { 
  const [clapState, setClapState] = useState(initialState)

  useEffect(() => {
    onClap && onClap(clapState)
  }, [count])

}
MediumClap.Icon = ClapIcon
MediumClap.Count = ClapCount
MediumClap.Total = ClapCountTotal

export default MediumClap
import MediumClap, { Icon, Count, Total } from 'medium-clap'

const Usage = () => {
  // expose count, countTotal and isClicked
  const [count, setCount] = useState(0)
  const handleClap = (clapState) => {
    setCount(clapState.count)
  }
  return (
    <div>
      <MediumClap onClap={handleClap}>
        <MediumClap.Icon />
        <MediumClap.Count />
        <MediumClap.Total />
      </MediumClap>
      <div className={styles.info}>{`You have clapped ${count}`}</div>
    </div>
  )
}

⬆ back to top

Invoking the useEffect callback only after mount!

useRef

  // 1. default value is false
  // 1. set to true when MediumClap is rendered
  const componentJustMounted = useRef(false)
  useEffect(() => {
    if(componentJustMounted.current){
      // 3. next time count changes
      console.log('onClap is called')
      onClap && onClap(clapState)
    } else {
      // 2. set to true the first time in useEffect after rendered
      componentJustMounted.current = true
    }
  }, [count])

⬆ back to top

Section 5: Patterns for Crafting Reusable Styles

Introduction to reusable styles

  • Regardless of the component you build, a common requirement is allowing the override and addition of new styles.
  • Allow users style your components like any other element/component in their app.

JSX feature

  • Inline style: <div style={{color:'red'}}>Hello</div>
  • className: <div className="red">Hello</div>
// Pass in style as props to reuse style
<MediumClap style={{ background: '#8cacea' }}>Hello</MediumClap>

const MediumClap = (style = {}) => <div style={style}></div>
// Pass in className as props to reuse style
import userStyles from './usage.css'
<MediumClap className={userStyles.clap}>Hello</MediumClap>

const MediumClap = (className) => <div className={className}></div>

Open-source examples

Pros Cons
Intuitive Style Overrides

Pros

  • Intuitive Style Overrides
    • Allow for style overrides in a way your users are already familiar with.

⬆ back to top

Extending styles via a style prop

// Pass in style as props to reuse style
<MediumClap style={{ background: '#8cacea' }}>Hello</MediumClap>

const MediumClap = (style = {}) => <div style={style}></div>
const MediumClap = ({ children, onClap, style : userStyles = {} }) => {
  ...
  return (
    <Provider value={memoizedValue}>
      <button 
        ref={setRef} 
        data-refkey="clapRef"
        className={styles.clap} 
        onClick={handleClapClick}
        style={userStyles}
      >
        {children}
      </button>
    </Provider>
  )
}

const ClapIcon = ({ style: userStyles = {} }) => {
  const { isClicked } = useContext(MediumClapContext)
  return (
    <span>
      <svg
        xmlns='http://www.w3.org/2000/svg'
        viewBox='-549 338 100.1 125'
        className={`${styles.icon} ${isClicked && styles.checked}`}
        style={userStyles}
      >
      ...
      </svg>
    </span>
  )
}

const ClapCount = ({ style: userStyles = {} }) => {
  const { count, setRef } = useContext(MediumClapContext)
  return (
    <span 
      ref={setRef} 
      data-refkey="clapCountRef" 
      className={styles.count}
      style={userStyles}
    >
      + {count}
    </span>
  )
}

const ClapCountTotal = ({ style: userStyles = {} }) => {
  const { countTotal, setRef } = useContext(MediumClapContext)
  return (
    <span 
      ref={setRef} 
      data-refkey="clapCountTotalRef" 
      className={styles.total}
      style={userStyles}
    >
      {countTotal}
    </span>
  )
}

MediumClap.Icon = ClapIcon
MediumClap.Count = ClapCount
MediumClap.Total = ClapCountTotal

const Usage = () => {
  const [count, setCount] = useState(0)
  const handleClap = (clapState) => { ... }
  return (
    <div style={{ width: '100%' }}>
      <MediumClap 
        onClap={handleClap} 
        style={{ border: '1px solid red' }}
      >
        <MediumClap.Icon />
        <MediumClap.Count style={{ background: '#8cacea' }} />
        <MediumClap.Total style={{ background: '#8cacea', color: 'black' }} />
      </MediumClap>
      {!!count && (
        <div className={styles.info}>{`You have clapped ${count} times`}</div>
      )}
    </div>
  )
}

export default Usage

⬆ back to top

Extending styles via a className prop

// Pass in className as props to reuse style
import userStyles from './usage.css'
<MediumClap className={userStyles.clap}>Hello</MediumClap>

const MediumClap = (className) => <div className={className}></div>
import userCustomStyles from './usage.css'

const MediumClap = ({ 
  children, 
  onClap, 
  style : userStyles = {}, 
  className 
}) => {
  ...
  const handleClapClick = () => { ... }
  const memoizedValue = useMemo( ... )

  // className -> 'clap-1234 classUser'
  const classNames = [styles.clap, className].join(' ').trim()

  return (
    <Provider value={memoizedValue}>
      <button 
        ref={setRef} 
        data-refkey="clapRef"
        className={classNames} 
        onClick={handleClapClick}
        style={userStyles}
      >
        {children}
      </button>
    </Provider>
  )
}

const ClapIcon = ({ style: userStyles = {}, className }) => {
  const { isClicked } = useContext(MediumClapContext)
  const classNames = [styles.icon, isClicked ? styles.checked : '', className].join(' ').trim()
  return (
    <span>
      <svg
        xmlns='http://www.w3.org/2000/svg'
        viewBox='-549 338 100.1 125'
        className={classNames}
        style={userStyles}
      >
        ...
      </svg>
    </span>
  )
}

const ClapCount = ({ style: userStyles = {}, className }) => {
  const { count, setRef } = useContext(MediumClapContext)
  const classNames = [styles.count, className].join(' ').trim()
  return (
    <span 
      ref={setRef} 
      data-refkey="clapCountRef" 
      className={classNames}
      style={userStyles}
    >
      + {count}
    </span>
  )
}

const ClapCountTotal = ({ style: userStyles = {}, className }) => {
  const { countTotal, setRef } = useContext(MediumClapContext)
  const classNames = [styles.total, className].join(' ').trim()
  return (
    <span 
      ref={setRef} 
      data-refkey="clapCountTotalRef" 
      className={classNames}
      style={userStyles}
    >
      {countTotal}
    </span>
  )
}

MediumClap.Icon = ClapIcon
MediumClap.Count = ClapCount
MediumClap.Total = ClapCountTotal

const Usage = () => {
  const [count, setCount] = useState(0)
  const handleClap = (clapState) => { ... }
  return (
    <div style={{ width: '100%' }}>
      <MediumClap 
        onClap={handleClap} 
        className={userCustomStyles.clap}
      >
        <MediumClap.Icon className={userCustomStyles.icon} />
        <MediumClap.Count className={userCustomStyles.count} />
        <MediumClap.Total className={userCustomStyles.total} />
      </MediumClap>
      {!!count && (
        <div className={styles.info}>{`You have clapped ${count} times`}</div>
      )}
    </div>
  )
}

export default Usage

⬆ back to top

Section 6: The Control Props Pattern

The Problem to be solved

Existing Compound Component

<MediumClap onClap={handleClap}>
  <ClapIcon />
  <ClapCount />
  <ClapCountTotal />
</MediumClap>

User has no control over local state clapState in MediumClap Compound Component

  • local state clapState consists of count, countTotal and isClicked

⬆ back to top

What is control props?

Controlled Components

  • Only the react component can update its state.
  • state property: keep the mutable state
  • callback handler: updated state with setState()
  • Example:
<input
  value={this.state.val}
  onChange={handleClap}
/>

What is control props?

  • Perhaps inspired by React’s controlled form elements, control props allow users of your component to control the UI state via certain “control” props.
  • Example: <MediumClap values={} onClap={}>
    • values: state values passed via props
    • onClap: state updater

Open-source examples

Pros Cons
Inversion of Control Duplicate code

Pros

  • Inversion of Control
    • Allow for style overrides in a way your users are already familiar with.

Cons

  • Duplicate code
    • For more complex scenarios, the user may have to duplicate some logic you’d have handled internally.

⬆ back to top

Implementing the pattern

const initialState = {
  count: 0,
  countTotal: 267,
  isClicked: false
}

const useClapAnimation = () => { ... }
const MediumClapContext = createContext()
const { Provider } = MediumClapContext

const MediumClap = ({ 
  children, 
  onClap,
  // 1. add a values prop
  values = null,
  style : userStyles = {}, 
  className 
}) => {
  const MAXIMUM_USER_CLAP = 50
  const [clapState, setClapState] = useState(initialState)
  ...
  // 2. Controlled component ?
  const isControlled = !!values && onClap

  // 3. Controlled component ? onClap : setClapState
  const handleClapClick = () => {
    animationTimeline.replay()
    isControlled 
      ? onClap() 
      : setClapState(prevState => ({ ... }))
  }

  const componentJustMounted = useRef(false)
  useEffect(() => {
    // 4. if not isControlled then use clapState
    if(componentJustMounted.current && !isControlled) {
      console.log('onClap is called')
      onClap && onClap(clapState)
    } else {
      componentJustMounted.current = true
    }
  }, [count, onClap, isControlled])

  // 5. Controlled component ? pass values to children : pass clapState to children 
  const getState = useCallback(
    () => isControlled ? values : clapState, 
    [isControlled, values, clapState]
  )
  const memoizedValue = useMemo(
    () => ({
      ...getState(), 
      setRef
    }), [getState, setRef]
  )

  const classNames = [styles.clap, className].join(' ').trim()
  return (
    <Provider value={memoizedValue}>
      <button 
        ref={setRef} 
        data-refkey="clapRef"
        className={classNames} 
        onClick={handleClapClick}
        style={userStyles}
      >
        {children}
      </button>
    </Provider>
  )
}

...

const Usage = () => {
  const [count, setCount] = useState(0)
  const handleClap = (clapState) => {
    setCount(clapState.count)
  }
  return (
    <div style={{ width: '100%' }}>
      <MediumClap 
        onClap={handleClap} 
        className={userCustomStyles.clap}
      >
        <MediumClap.Icon className={userCustomStyles.icon} />
        <MediumClap.Count className={userCustomStyles.count} />
        <MediumClap.Total className={userCustomStyles.total} />
      </MediumClap>
      {!!count && (
        <div className={styles.info}>{`You have clapped ${count} times`}</div>
      )}
    </div>
  )
}

export default Usage

⬆ back to top

Practical usage of control props

const INITIAL_STATE = {
  count: 0,
  countTotal: 2100,
  isClicked: false
}
const MAXIMUM_CLAP_VAL = 10

const Usage = () => {
  const [state, setState] = useState(INITIAL_STATE)
  const [count, setCount] = useState(0)
  const handleClap = (clapState) => {
    clapState 
    ? setCount(clapState.count) 
    : setState(({ count, countTotal }) => ({
      count:  Math.min(count + 1, MAXIMUM_CLAP_VAL),
      countTotal: count < MAXIMUM_CLAP_VAL ? countTotal + 1 : countTotal,
      isClicked: true
    }))
  }

  return (
    <div style={{ width: '100%' }}>
      <MediumClap 
        onClap={handleClap} 
        className={userCustomStyles.clap}
      >
        <MediumClap.Icon className={userCustomStyles.icon} />
        <MediumClap.Count className={userCustomStyles.count} />
        <MediumClap.Total className={userCustomStyles.total} />
      </MediumClap>

      <MediumClap
        values={state}
        onClap={handleClap} 
        className={userCustomStyles.clap}
      >
        <MediumClap.Icon className={userCustomStyles.icon} />
        <MediumClap.Count className={userCustomStyles.count} />
        <MediumClap.Total className={userCustomStyles.total} />
      </MediumClap>

      <MediumClap
        values={state}
        onClap={handleClap} 
        className={userCustomStyles.clap}
      >
        <MediumClap.Icon className={userCustomStyles.icon} />
        <MediumClap.Count className={userCustomStyles.count} />
        <MediumClap.Total className={userCustomStyles.total} />
      </MediumClap>
      
      {!!count && (
        <div className={styles.info}>{`You have clapped internal count ${count} times`}</div>
      )}
      {!!state.count && (
        <div className={styles.info}>{`You have clapped user count ${state.count} times`}</div>
      )}
    </div>
  )
}

export default Usage

⬆ back to top

Section 7: Custom Hooks: A Deeper Look at the Foundational Pattern

Introduction

<MediumClap /> -> Logic + UI components <MediumClap /> -> L + O + G + I + C + UI components

Extract logic into small custom reusable hooks

  • useClapAnimation
  • useDOMRef
  • UseEffectAfterMount
  • useClapState

Custom reusable hook consists of

  • state property: keep the mutable state
  • callback handler: updated state with setState()

⬆ back to top

useDOMRef

const useDOMRef = () => {
  const [DOMRef, setRefState] = useState({})
  
  const setRef = useCallback(node => {
    setRefState(prevRefState => ({
      ...prevRefState,
      [node.dataset.refkey]: node
    }))
  }, [])

  return [DOMRef, setRef]
}

⬆ back to top

useClapState

const useClapState = (initialState = INITIAL_STATE) => {
  const MAXIMUM_USER_CLAP = 50
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal } = clapState

  const updateClapState = useCallback(() => {
    setClapState(({ count, countTotal }) => ({
      count: Math.min(count + 1, MAXIMUM_USER_CLAP),
      countTotal: 
        count < MAXIMUM_USER_CLAP 
          ? countTotal + 1 
          : countTotal,
      isClicked: true,
    }))
  },[count, countTotal])

  return [clapState, updateClapState]
}

⬆ back to top

useEffectAfterMount

const useEffectAfterMount = (cb, deps) => {
  const componentJustMounted = useRef(true)
  useEffect(() => {
    if(!componentJustMounted.current){
      return cb()
    }
    componentJustMounted.current = false
  }, deps)
}

⬆ back to top

Section 8: The Props Collection Pattern

What are props collections?

Props Collection refer to a collection of common props users of your components/hooks are likely to need.

  • <Train seat strap foo/> -> <Train collection>
  • <Train seat strap foo/> -> <Train collection>
  • <Train seat strap foo/> -> <Train collection>
  • <Train seat strap foo/> -> <Train collection>
// propsCollection: Typically an object 
// e.g. { prop1, prop2, prop3 }
const { propsCollection } = useYourHook()
Pros Cons
Ease of Use Inflexible

Pros

  • Ease of Use
    • This pattern exists mostly for the convenience it brings the users of your component/hooks.

Cons

  • Inflexible
    • The collection of props can’t be modified or extended.

⬆ back to top

Implementing props collections

const useClapState = (initialState = INITIAL_STATE) => {
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal } = clapState
  const updateClapState = useCallback(() => {
    setClapState(({ count, countTotal }) => ({ ... },[count, countTotal])

  const togglerProps = {
    onClick: updateClapState
  }

  const counterProps = {
    count
  }

  return { clapState, updateClapState, togglerProps, counterProps }
}

const Usage = () => {
  const { 
    clapState, 
    updateClapState, 
    togglerProps, 
    counterProps 
  } = useClapState()
  const { count, countTotal, isClicked } = clapState
  const [{ clapRef, clapCountRef, clapCountTotalRef }, setRef] = useDOMRef()
  const animationTimeline = useClapAnimation({ ... })
  useEffectAfterMount(() => { ... }, [count])

  return (
    <ClapContainer 
      setRef={setRef}    
      data-refkey="clapRef"
      {...togglerProps}
    >
      <ClapIcon isClicked={isClicked} />
      <ClapCount 
        setRef={setRef}
        data-refkey="clapCountRef" 
        {...counterProps}
      />
      <CountTotal 
        countTotal={countTotal}
        setRef={setRef}
        data-refkey="clapCountTotalRef" />
    </ClapContainer>
  )
}

⬆ back to top

Accessibility and props collections

const useClapState = (initialState = INITIAL_STATE) => {
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal } = clapState
  const updateClapState = useCallback(() => {
    setClapState(({ count, countTotal }) => ({ ... }))
  },[count, countTotal])

  const togglerProps = {
    onClick: updateClapState,
    'aria-pressed': clapState.isClicked
  }

  const counterProps = {
    count,
    'aria-valuemax': MAXIMUM_USER_CLAP,
    'aria-valuemin': 0,
    'aria-valuenow': count,
  }

  return { clapState, updateClapState, togglerProps, counterProps }
}

⬆ back to top

Section 9: The Props Getters Pattern

What are props getters

Props getters, very much like props collection, provide a collection of props to users of your hooks/component. The difference being the provision of a getter - a function invoked to return the collection of props.

  • Props Collection is an object
  • Props Getter is a function
// getPropsCollection is a function. 
// When invoked, returns an object
// e.g. { prop1, prop2, prop3 }
const { getPropsCollection } = useYourHook()

The added advantage a prop getter has is it can be invoked with arguments to override or extend the collection of props returned.

  const { getPropsCollection } = useYourHook()
  const propsCollection = getPropsCollection({
    onClick: myClickHandler,
    // user specific values may be passed in.
    data-testId: `my-test-id`
  })

Open-source examples

⬆ back to top

From collections to getters

const useClapState = (initialState = INITIAL_STATE) => {
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal } = clapState
  const updateClapState = useCallback(() => {
    setClapState(({ count, countTotal }) => ({ ... },[count, countTotal])

  const getTogglerProps = () => ({
    onClick: updateClapState
  })

  const getCounterProps = () => ({
    count
  })

  return { clapState, updateClapState, getTogglerProps, getCounterProps }
}

const Usage = () => {
  const { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps 
  } = useClapState()
  const { count, countTotal, isClicked } = clapState
  const [{ clapRef, clapCountRef, clapCountTotalRef }, setRef] = useDOMRef()
  const animationTimeline = useClapAnimation({ ... })
  useEffectAfterMount(() => { ... }, [count])

  return (
    <ClapContainer 
      setRef={setRef}    
      data-refkey="clapRef"
      {...getTogglerProps()}
    >
      <ClapIcon isClicked={isClicked} />
      <ClapCount 
        setRef={setRef}
        data-refkey="clapCountRef" 
        {...getCounterProps()}
      />
      <CountTotal 
        countTotal={countTotal}
        setRef={setRef}
        data-refkey="clapCountTotalRef" />
    </ClapContainer>
  )
}

⬆ back to top

Use cases for prop getters

const Usage = () => {
  const { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps 
  } = useClapState()
  const { count, countTotal, isClicked } = clapState
  const [{ clapRef, clapCountRef, clapCountTotalRef }, setRef] = useDOMRef()
  const animationTimeline = useClapAnimation({ ... })
  useEffectAfterMount(() => { ... }, [count])

  // use case 1: user can change 'aria-pressed' value in props
  // use case 2: user can pass in onClick handler
  return (
    <ClapContainer 
      setRef={setRef}    
      data-refkey="clapRef"
      {...getTogglerProps({
        'aria-pressed': false,
        onClick: handleClick
      })}
    >
      <ClapIcon isClicked={isClicked} />
      <ClapCount 
        setRef={setRef}
        data-refkey="clapCountRef" 
        {...getCounterProps()}
      />
      <CountTotal 
        countTotal={countTotal}
        setRef={setRef}
        data-refkey="clapCountTotalRef" />
    </ClapContainer>
  )
}
// reference to:
// const handleClick = event => { ... }
// <button onClick={handleClick} />

// callFnsInSequence take in many functions (...fns)
// and return a function 
// (...args) => { fns.forEach(fn => fn && fn(...args)) }
// all arguements (...args) of functions e.g. event
const callFnsInSquence = (...fns) => (...args) => {
  fns.forEach(fn => fn && fn(...args))
}

const useClapState = (initialState = INITIAL_STATE) => {
  const MAXIMUM_USER_CLAP = 50
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal } = clapState

  const updateClapState = useCallback(() => {
    setClapState(({ count, countTotal }) => ({ ... }))
  },[count, countTotal])

  const getTogglerProps = ({ onClick, ...otherProps }) => ({
    onClick: callFnsInSquence(updateClapState, onClick),
    'aria-pressed': clapState.isClicked,
    ...otherProps
  })

  const getCounterProps = ({ ...otherProps }) => ({
    count,
    'aria-valuemax': MAXIMUM_USER_CLAP,
    'aria-valuemin': 0,
    'aria-valuenow': count,
    ...otherProps
  })

  return { clapState, updateClapState, getTogglerProps, getCounterProps }
}

⬆ back to top

Section 10: The State Initialiser Pattern

What are state initializers?

A simple pattern that allows for configurable initial state, and an optional state reset handler.

State Managed = Motion

  • Initial State: Not in Motion
  • Update State: Accelerator / Gas Pedal
  • Reset State: Brake
// user may call reset fn to reset state
// user passes in some initial state value
const { value, reset } = useYourHook(initialState)

// initialState is passed into your internal state mechanism
const [internalState] = useState(initialState)

Open-source examples

Passing props to state is generally frowned upon, which is why you have to make sure the value passed here is only an initialiser.

Pros Cons
Important Feature for Most UIs May be Trivial

Pros

  • Important Feature for Most UIs
    • Setting and resetting state is typically a very important requirement for most UI components. This gives a lot of flexibility to your users.

Cons

  • May be Trivial
    • You may find yourself building a component/custom hook where state initialisers are perhaps trivial.

⬆ back to top

First pattern requirement

MediumClap Exercise

  • Initial State: pass userInitialState into useClapState
  • Update State:
  • Reset State:
const userInitialState = {
  count: 20,
  countTotal: 1000,
  isClicked: true
}

const Usage = () => {
  const { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps
  } = useClapState(userInitialState)
  ...
}

⬆ back to top

Handling resets

MediumClap Exercise

  • Initial State: pass userInitialState into useClapState
  • Update State: call updateClapState handler returned by useClapState
  • Reset State: call reset handler returned by useClapState
const useClapState = (initialState = INITIAL_STATE) => {
  const MAXIMUM_USER_CLAP = 50
  const userInitialState = useRef(initialState)
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal } = clapState

  const updateClapState = useCallback(() => {
    setClapState(({ count, countTotal }) => ({ ... }))
  },[count, countTotal])

  const reset = useCallback(() => {
    setClapState(userInitialState.current)
  }, [setClapState])

  const getTogglerProps = ({ onClick, ...otherProps }) => ({ ... })
  const getCounterProps = ({ ...otherProps }) => ({ ... })

  return { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps, 
    reset 
  }
}

const Usage = () => {
  const { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps,
    reset
  } = useClapState(userInitialState)
  ...
}

⬆ back to top

Handling reset side effects

const useClapState = (initialState = INITIAL_STATE) => {
  const MAXIMUM_USER_CLAP = 5
  const userInitialState = useRef(initialState)
  const [clapState, setClapState] = useState(initialState)
  const { count, countTotal } = clapState
  const updateClapState = useCallback(() => { ... },[count, countTotal])

  const resetRef = useRef(0) // reset counter: start from 0
  const prevCount = usePrevious(count) // get prev count
  const reset = useCallback(() => {
    // reset only if prev < count or prev reach MAXIMUM_USER_CLAP
    if(prevCount < count || prevCount === MAXIMUM_USER_CLAP) {
      setClapState(userInitialState.current)  // reset clap state
      resetRef.current++  // update reset counter: 0, 1, 2, 3, ...
    }
  }, [prevCount, count, setClapState])

  const getTogglerProps = ({ onClick, ...otherProps }) => ({ ... })
  const getCounterProps = ({ ...otherProps }) => ({ ... })

  return { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps, 
    reset, // reset function
    resetDep: resetRef.current  // reset counter
  }
}
const Usage = () => {
  const { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps,
    reset,
    resetDep
  } = useClapState(userInitialState)

  const { count, countTotal, isClicked } = clapState
  const [{ clapRef, clapCountRef, clapCountTotalRef }, setRef] = useDOMRef()
  const animationTimeline = useClapAnimation({ ... })
  useEffectAfterMount(() => { ... }, [count])

  // simulate uploading api call
  const [uploadingReset, setUpload] = useState(false)
  useEffectAfterMount(() => {
    setUpload(true)

    const id = setTimeout(() => {
      setUpload(false)
    }, 3000)

    return () => clearTimeout(id)
  }, [resetDep])

  const handleClick = () => console.log("CLICKED!!!")
  
  return (
    <div>
      <ClapContainer 
        setRef={setRef}    
        data-refkey="clapRef"
        {...getTogglerProps({
          'aria-pressed': false,
          onClick: handleClick
        })}
      >
        <ClapIcon isClicked={isClicked} />
        <ClapCount 
          setRef={setRef}
          data-refkey="clapCountRef" 
          {...getCounterProps()}
        />
        <CountTotal 
          countTotal={countTotal}
          setRef={setRef}
          data-refkey="clapCountTotalRef" />
      </ClapContainer>
      <section>
        <button 
          onClick={reset} 
          className={userStyles.resetBtn}
          disabled={uploadingReset}
        >
          reset
        </button>
        <pre className={userStyles.resetMsg}>
          {JSON.stringify({count, countTotal, isClicked})}
        </pre>
        <pre className={userStyles.resetMsg}>
          { uploadingReset ? `uploading reset ${resetDep} ...` : '' }
        </pre>
      </section>
    </div>
    
  )
}

⬆ back to top

How usePrevious works

How to get previous props/state with React Hooks

// use effect is never called until the return statement of the functional component is reached.

// 1st run: value = 0, ref.current = undefined, return undefined, update ref.current = 0
// 2nd run: value = 1, ref.current = 0, return 0, update ref.current = 1
// 3rd run: value = 2, ref.current = 1, return 1, update ref.current = 2
// ...
const usePrevious = value => {
  const ref = useRef()

  useEffect(() => {
    ref.current = value // 2. then run use effect
  }, [value])
  return ref.current  // 1. return 1st
}

⬆ back to top

Section 11: The State Reducer Pattern

The state reducer pattern

Like the control props pattern, state reducers allow you to cede state control to the users of your component. Also, by leveraging action types, you minimise code duplicates on the user’s side.

Reducers: update internal state with action

<MediumClap reducer={reducer} />

(state, action) => {
  return newState
}

Open-source examples

Pros Cons
Ultimate Inversion of Control Complexity

Pros

  • Ultimate Inversion of Control
    • State reducers in more complicated use cases are the best way to cede control over to the users of your component/custom hooks.

Cons

  • Complexity
    • The pattern is arguably the most complex of the bunch to implement.

⬆ back to top

From useState to useReducer

const MAXIMUM_USER_CLAP = 50
const reducer = ({ count, countTotal }, { type, payload }) => {
  switch (type) {
    case 'clap':
      return {
        count: Math.min(count + 1, MAXIMUM_USER_CLAP),
        countTotal: 
          count < MAXIMUM_USER_CLAP 
            ? countTotal + 1 
            : countTotal,
        isClicked: true,
      }
    case 'reset': 
      return payload
    default:
      break;
  }
  return state
}
const useClapState = (initialState = INITIAL_STATE) => {
  const userInitialState = useRef(initialState)
  // from useState to useReducer
  const [clapState, dispatch] = useReducer(reducer, initialState)
  const { count, countTotal } = clapState
  
  // not pass down to user, can remove useCallback
  // dispatch clap action
  const updateClapState = () => dispatch({ type: 'clap' })

  const resetRef = useRef(0) // 0, 1, 2, 3, ...
  const prevCount = usePrevious(count)
  const reset = useCallback(() => {
    if(prevCount < count || prevCount === MAXIMUM_USER_CLAP) {
      // dispatch reset action
      dispatch({ type: 'reset', payload: userInitialState.current })
      resetRef.current++
    }
  }, [prevCount, count, dispatch])

  const getTogglerProps = ({ onClick, ...otherProps }) => ({ ... })
  const getCounterProps = ({ ...otherProps }) => ({ ... })

  return { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps, 
    reset, 
    resetDep: resetRef.current
  }
}

⬆ back to top

Passing a user custom reducer

const useClapState = (
  initialState = INITIAL_STATE, 
  reducer = internalReducer
) => { ... }
const Usage = () => {
  const reducer = ({ count, countTotal }, { type, payload }) => {
    switch (type) {
      case 'clap':
        return { 
          count: Math.min(count + 1, 10),
          countTotal: count < 10 ? countTotal + 1 : countTotal,
          isClicked: true
        }
      case 'reset':
          return payload
      default:
        break;
    }
  }
  const { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps,
    reset,
    resetDep
  } = useClapState(userInitialState, reducer) // user custom reducer

  const { count, countTotal, isClicked } = clapState
  const [{ clapRef, clapCountRef, clapCountTotalRef }, setRef] = useDOMRef()
  const animationTimeline = useClapAnimation({ ... })
  useEffectAfterMount(() => { ... }, [count])

  const [uploadingReset, setUpload] = useState(false)
  useEffectAfterMount(() => { ... }, [resetDep])
  const handleClick = () => console.log("CLICKED!!!")
  return ( ... )
}

export default Usage

⬆ back to top

Exposing the internal reducer and types

const useClapState = (
  initialState = INITIAL_STATE, 
  reducer = internalReducer
) => {
  const userInitialState = useRef(initialState)
  const [clapState, dispatch] = useReducer(reducer, initialState)
  const { count, countTotal } = clapState
  
  const updateClapState = () => dispatch({ type: 'clap' })

  const resetRef = useRef(0) // 0, 1, 2, 3, ...
  const prevCount = usePrevious(count)
  const reset = useCallback(() => {
    if(prevCount < count  
      || (prevCount === count && prevCount!== 0)) {
      dispatch({ type: 'reset', payload: userInitialState.current })
      resetRef.current++
    }
  }, [prevCount, count, dispatch])

  const getTogglerProps = ({ onClick, ...otherProps }) => ({ ... })
  const getCounterProps = ({ ...otherProps }) => ({ ... })

  return { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps, 
    reset, 
    resetDep: resetRef.current
  }
}

// expose the internal reducer and types
useClapState.reducer = internalReducer
useClapState.types = { clap: 'clap', reset: 'reset'}
const Usage = () => {
  const [timesClapped, setTimesClapped] = useState(0)
  const isClappedTooMuch = timesClapped >= 7

  // user can modify existing internal reducer
  const reducer = (state, action) => {
    if(action.type === useClapState.types.clap && isClappedTooMuch) {
      return state
    }
    return useClapState.reducer(state, action)
  }
    const { 
    clapState, 
    updateClapState, 
    getTogglerProps, 
    getCounterProps,
    reset,
    resetDep
  } = useClapState(userInitialState, reducer)
  ...
}

Section 12: (Bonus) Classifying the Patterns: How to choose the best API

How the classification works

  • User Interface <-> Logic (x-axis)
  • Difficulty (y-axis)

⬆ back to top

Making the right API choice

Difficulty User Interface User Interface and Logic Logic
Easy Reusable Styles Props Collection & Getters
Intermediate Compound Components State Initialisers Custom Hooks
Hard Control Props
Hard State Reducer

⬆ back to top

Releases

No releases published

Packages

No packages published