- The Complete Guide to Advanced React Component Patterns
- Table of Contents
- Section 1: Introduction
- Section 2: The Medium Clap: Real-world Component for Studying Advanced React Patterns
- Why build the medium clap?
- Building and styling the medium clap
- Handling user interactivity
- Higher order components recap
- Beginning to animate the clap
- Creating and updating the animation timeline
- Resolving wrong animated scale
- Animating the total count
- Animating the clap count
- Creating animated bursts!
- Section 3: Custom Hooks: The first Foundational Pattern
- Section 4: The Compound Components Pattern
- Section 5: Patterns for Crafting Reusable Styles
- Section 6: The Control Props Pattern
- Section 7: Custom Hooks: A Deeper Look at the Foundational Pattern
- Section 8: The Props Collection Pattern
- Section 9: The Props Getters Pattern
- Section 10: The State Initialiser Pattern
- Section 11: The State Reducer Pattern
- Section 12: (Bonus) Classifying the Patterns: How to choose the best API
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
Building and styling the medium clap
- The default State of the Component - unclicked
- The Clap Count & Burst Shown
- The Clap Total count Show
<button>
<ClapIcon />
<ClapCount />
<CountTotal />
</button>
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>
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>
console.log timestamps in Chrome
- Console: (...) -> Settings -> Preferences -> Console -> [x] Show timestamps
- 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)
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)
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)
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)
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)
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)
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
}
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.
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.
- 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
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
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
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.
- 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>
- 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
- Create a context object to pass values into the tree of child components in parent component
- Use a Provider to pass the current value to the tree below
- Returns a memoized state
- Accepts a value prop to be passed to consuming components that are descendants of this Provider
- Use the special children prop to pass children elements directly into Parent component
- 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>
)
}
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
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>
)
}
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>
)
}
// 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])
- 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.
// 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
// 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
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
- 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.
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
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
<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()
const useDOMRef = () => {
const [DOMRef, setRefState] = useState({})
const setRef = useCallback(node => {
setRefState(prevRefState => ({
...prevRefState,
[node.dataset.refkey]: node
}))
}, [])
return [DOMRef, setRef]
}
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]
}
const useEffectAfterMount = (cb, deps) => {
const componentJustMounted = useRef(true)
useEffect(() => {
if(!componentJustMounted.current){
return cb()
}
componentJustMounted.current = false
}, deps)
}
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.
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>
)
}
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 }
}
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
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>
)
}
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 }
}
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.
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)
...
}
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)
...
}
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>
)
}
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
}
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.
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
}
}
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
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)
...
}
- User Interface <-> Logic (x-axis)
- Difficulty (y-axis)
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 |