DEV Community

tkow
tkow

Posted on

What's the best way migrates state to outside of component on React?

We often make ui with control show/hidden like below a slider of pseudo code.

// Omitting import 
export default function SliderShowButton(props: SliderShowButtonProps) {
  const [showSlider, setShowSlider] = useState(false)
  const toggleSlider = useCallback(() => {
    setShowSlider((state) => !state)
  }, [])

  return (
    <ButtonContainer>
      <SliderShowButtonStyle title="Font" value={'Font'} onClick={toggleSlider}>
        <Icon name="font" size={20} />
      </SliderShowButtonStyle>
      {showSlider && (
        <Slider value={props.value} onChangeSize={props.onChangeSize} />
      )}
    </ButtonContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

First, we expect it's enough to control that clickable button that toggles show/hidden inside the component. However, we will often desire to toggle it in the other place. For example, we may want it's forced to be hidden slider at body click-event.

In this case, the above code will be needed rewritten, but many ways to do so and we often consider "what's the best?".
I'll suppose several ways in this article, but there're tradeoff and weak points in any of them. So, if you know more wonderful way, let me know it.

1. Up internal state to parent, and pass them to props

Simplest and convenient way to do so. And we recommend first if the component is reusable in many place.

Pros

  • intuitive
  • shared states' scope is obvious

Cons

  • control do as like not stable(show/hide logic is stable against button click but it makes it abstract), but this is avoidable by setting the default state and modifier(though it's a use subtle useless memory capacity if you don't use them)
  • If many components do so, scope of top parent has many trivial states and props relay.
  • you may need to rewrite all components using the slider to inject state (It is avoidable to duplicate and keep the old component or default logic like example)

Example

// Omitting import 

type SliderShowButtonProps = {
  value: number
  onChangeFontSize: (fontSize: number) => void
  showSlider?: boolean
  setShowSlider?: (flg: boolean) => void
}

export default function SliderShowButton(props: SliderShowButtonProps) {
  const _default = useState(false)
  const [ showSlider = _default[0], setShowSlider = _default[1] ] = [props.showSlider, props.setShowSlider]
  const toggleSlider = useCallback(() => {
    setShowSlider(!showSlider)
  }, [showSlider, setShowSlider])

  return (
    <ButtonContainer>
      <SliderShowButtonStyle title="Font" value={'Font'} onClick={toggleSlider}>
        <Icon name="font" size={20} />
      </SliderShowButtonStyle>
      {showSlider && (
        <Slider value={props.value} onChangeSize={props.onChangeSize} />
      )}
    </ButtonContainer>
  )
}

// parent
function Parent() {
  const [showSlider, setShowSlider] = useState(false)

  useEffect(() => {
    setShowSlider(false)
  }, [someHook])

  return (
    <ParentContainer>
      <SliderShowButton showSlider={showSlider} setShowSlider={setShowSlider} />
    </ParentContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

2. Context

This is also recommended way, if you can understand where the context is used in all the components. And best way as I thought if you need to ready for the unexpected extension.

Pros

  • intuitive
  • reusable in wide scope not only parent
  • reduce passing props and state definitions from parent.

Cons

  • you should extract presentational components and insert provider into each using slider component, otherwise unclearly share states.
  • shared states' scope is unclear little (you can avoid this by file structure)
  • you may need to add all components using the slider to inject Provider.

Example

// Omitting import 

type SliderContext = {
  showSlider: boolean
  setShowSlider: (flg: boolean) => void
}

const SliderContext = createContext<SliderContext({} as SliderContext)

type SliderShowButtonProps = {
  value: number
  onChangeFontSize: (fontSize: number) => void
  showSlider?: boolean
  setShowSlider?: (flg: boolean) => void
}

// you may keep to export as presentational component
function SliderShowButton(props: SliderShowButtonProps) {
  const [ showSlider, setShowSlider ] = [props.showSlider, props.setShowSlider]
  const toggleSlider = useCallback(() => {
    setShowSlider(!showSlider)
  }, [showSlider, setShowSlider])

  return (
    <ButtonContainer>
      <SliderShowButtonStyle title="Font" value={'Font'} onClick={toggleSlider}>
        <Icon name="font" size={20} />
      </SliderShowButtonStyle>
      {showSlider && (
        <Slider value={props.value} onChangeSize={props.onChangeSize} />
      )}
    </ButtonContainer>
  )
}

export function Provider({ children }: { children: ReactElement }) {
  const [showSlider, setShowSlider] = useState(false)
  return (
    <SliderContext.Provider value={{ showSlider, setShowSlider }} />
      {children}
    </SliderContext.Provider>
  )
}

export default function (
  props: Omit<FontButtonProps, 'showSlider' | 'setShowSlider'>,
) {
  const { showSlider, setShowSlider } = useContext<SliderContext>(SliderContext)
  return (
    <SliderShowButton
      {...props}
      showSlider={showSlider}
      setShowSlider={setShowSlider}
    />
  )
}

// parent
function Parent() {
  const [showSlider, setShowSlider] = useContext(SliderContext)
  useEffect(() => {
    setShowSlider(false)
  }, [someHook])
  return (
    <ParentContainer>
      <SliderShowButton />
    </ParentContainer>
  )
}

export default function () {
  return (<Provider><Parent /></Provider>)
}
Enter fullscreen mode Exit fullscreen mode

3. Ref

Ref is intuitive, if callback is idempotent. If its' not, it's very confused so, use this pattern carefully. But, if interface is pretty concise, it's used like class instance and convenient in some cases.

Pros

  • arbitrary bring state and function outside scope
  • intuitive if you use it like class instance
  • in most cases, you need to revise just only slider component to keep compatibility

Cons

  • forwardedRef and ref may make your code dirty or more writing codes
  • control become more tough if ref method has side effect or depends on processing order
  • if other components have many refs, parent have many refs

Example

// Omitting import 

type SliderShowButtonProps = {
  value: number
  onChangeFontSize: (fontSize: number) => void

}

const SliderShowButton = forwardedRef(function (props: SliderShowButtonProps, ref) {
  const [ showSlider, setShowSlider ] = useState(false)
  const toggleSlider = useCallback(() => {
    setShowSlider((state) => !state)
  }, [])


  useImperativeHandle(ref, () => ({
    setShowSlider: setShowSlider
  }));


  return (
    <ButtonContainer>
      <SliderShowButtonStyle title="Font" value={'Font'} onClick={toggleSlider}>
        <Icon name="font" size={20} />
      </SliderShowButtonStyle>
      {showSlider && (
        <Slider value={props.value} onChangeSize={props.onChangeSize} />
      )}
    </ButtonContainer>
  )
}

export default SliderShowButton

// parent
function Parent() {
  const ref = useRef<{
    setShowSlider: (flg: boolean) => void
  }>
  useEffect(() => {
    ref.current.setShowSlider(false)
  }, [someHook])
  return (
    <ParentContainer>
      <SliderShowButton ref={ref} />
    </ParentContainer>
  )
}

export default function () {
  return (<Provider><Parent /></Provider>)
}
Enter fullscreen mode Exit fullscreen mode

4. useEffect(Not recommended, should avoid this if possible)

Intuitively, you think useEffect can watch props and synchronize internal state with it as first. But, this is a bad idea. The reason is that the state can be synced to props
value but the opposite is allowed means two-way binding. Two-way binding causes many serious bug like infinite loop or chaos control of simultaneous events. In addition, useEffect function is called after mount component event, then setState trigger re-rendering so it results redundant process. Thus you should avoid this pattern as possible.

Pros

  • less code to keep compatibility

Cons

  • it may cause two-binding and dangerous and obscure bug
  • it render twice props change of outer side.
  • need to add passing props from parent
// Omitting import 

type SliderShowButtonProps = {
  value: number
  onChangeFontSize: (fontSize: number) => void
  showSlider?: boolean
}

export default function SliderShowButton(props: SliderShowButtonProps) {
  const _default = useState(false)
  const [ showSlider, setShowSlider ] = [props.showSlider, props.setShowSlider]
  const toggleSlider = useCallback(() => {
    setShowSlider(!showSlider)
  }, [showSlider, setShowSlider])

  useEffect(() => {
    if(props.showSlider !== showSlider) {
      setShowSlider(props.showSlider)
    }
  })

  return (
    <ButtonContainer>
      <SliderShowButtonStyle title="Font" value={'Font'} onClick={toggleSlider}>
        <Icon name="font" size={20} />
      </SliderShowButtonStyle>
      {showSlider && (
        <Slider value={props.value} onChangeSize={props.onChangeSize} />
      )}
    </ButtonContainer>
  )
}

// parent
function Parent() {
  const [showSlider, setShowSlider] = useState(false)

  useEffect(() => {
    setShowSlider(false)
  }, [someHook])

  return (
    <ParentContainer>
      <SliderShowButton showSlider={showSlider} />
    </ParentContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the above example, you may think why you don't set showSlider and props.showSlider as deps. Even if you set them, if local showSlider change first and it doesn't sync props.showSlider as like the above example, it can't force to change local showSlider with same props.showSlider value because useEffect judge it's not changed value. So, effect function call every time which tends to bug. So this pattern should be avoided if possible.

5. Key prop(only available in specific case)

In special case if you want just only re-initialize
the component. Changing key prop another value.
See the detail of official doc. If slider always hidden in ancestor's events trigger and it's initialized state, we can use this.

Pros

  • less code to keep compatibility

Cons

  • its' not used if need effects depends on state.
  • not intuitive if anyone don't know the key prop feature.

Example

// parent
function Parent() {
  const [resetKey, setReset] = useState(uuid())

  useEffect(() => {
    setReset(uuid())
  }, [someHook])

  return (
    <ParentContainer>
      <SliderShowButton key={resetKey} />
    </ParentContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

I recommend the 1st, 2nd, 3rd ways in order, and to avoid 4th way. If you need only to reset component 5th way is available. If you know other ways, I'm glad to let me know it. Thank you for reading!

Top comments (0)