얼마전, React 컴포넌트를 향상시키기 위한 state reducer pattern이라고 불리는 새로운 패턴을 개발했습니다. 이것을 downshift 내부 상태를 업데이트 하고 싶어하는 사람들에게 훌륭한 API 사용을 가능하게 하기 위해 downshift에서 사용하였습니다.
만약 downshift에 익숙하지 않다면, 접근 가능한 autocomplete/typeahead/dropdown 컴포넌트들과 같은 것을 만들 수 있는 '향상된 input' 컴포넌트 라는 것만 알면 됩니다. `isOpen`, `selectedItem`, `highlightedIndex`와 `inputValue와 같은 상태를 관리한다는 것을 아는것은 중요합니다.
Downshift는 현재 prop component를 render하도록 구현되어 있습니다. 그 당시에 prop을 렌더하는 것은 UI의 고정 없이 로직을 공유할수 있게 해주는 "Headless UI Component" 를 만들기 위한 최고의 방법이었습니다. 이것이 downshift가 성공적이었던 주요 이유중 하나입니다.
오늘날에는 React hook이 있고 hook은 rener props 보다 더 잘 수행할 수 있는 방법입니다. 그래서 이 패턴이 React 팀이 제공한 새로운 API를 이용해 어떻게 업데이트 될 수 있는지 보여줘야 겠다고 생각했습니다. (Note: Downshift has plans to implement a hook)
다시 상기시키는 개념으로 state reducer 패턴의 이점은 사실 "inversion of control" 을 허용한다는 것에 있습니다. "inversion of control"은 API를 사용하는 이에게 내부적으로 어떻게 동작할지에 대한 권한을 부여하는 기본적인 메커니즘 입니다. 이에 대한 예시를 들어보자면 아래의 React Rally 2018을 보는것을 강력하게 권장합니다.
https://www.youtube.com/watch?list=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf&v=AiJ8tRRH0f8&feature=youtu.be
Read also on my blog: "Inversion of Control"
downshift 예제에서 유저가 아이템을 선택했을때 isOpen 상태가 false로 설정될 수 있도록 결정을 내렸습니다.(메뉴는 닫힐수 있습니다.)
누군가는 downshift와 함께 복수 선택을 만들고 있었고 유저가 메뉴의 아이템을 선택한 이후에도 열려있기를 원했습니다.(그래야지 그들이 더 많은 것을 선택할 수 있으니까요)
state reducer 패턴을 이용한 상태 업데이트에 대한 제어의 역전으로 그들의 경우와 downshift 내부적인 동작들을 변경하고 싶어하는 가능성이 있을 수 있는 사람들의 경우에도 가능하게 할 수 있었습니다. 제어의 역전은 컴퓨터 공학 원칙을 가능하게 해주었고 state reducer 패턴은 일반 컴포넌트에서 동작되었던 것보다 훅으로 더 잘 변환되는 놀라운 구현입니다.
Using a State Reducer with Hooks
자, 개념은 아래와 같이 동작합니다 :
- 사용자가 작업을 수행합니다.
- dispatch를 호출합니다.
- 훅은 변화가 필요한지 결정합니다.
- 훅이 이후의 변화를 위한 코드를 호출합니다. 👈 제어의 역전이 이뤄지는 부분입니다.
- 훅은 상태를 변화시킵니다.
WARNING: Contrived example ahead: 단순함을 유지하기 위해 단순한 `useToggle` 훅과 간단한 컴포넌틑를 사용할 겁니다. 조작되었다고 느낄수 있지만 이 패턴을 훅으로 동작시키는 것을 알려주는데 있어 복잡한 예시로 혼란을 주고 싶지 않습니다. 복잡한 훅과 컴포넌트에 적용했을때 이 패턴이 잘 동작한다는 것을 아는게 중요합니다.
function useToggle() {
const [on, setOnState] = React.useState(false)
const toggle = () => setOnState(o => !o)
const setOn = () => setOnState(true)
const setOff = () => setOnState(false)
return { on, toggle, setOn, setOff }
}
function Toggle() {
const {on, toggle, setOn, setOff} = useToggle()
return (
<div>
<button onClick={setOff}>Switch Off</button>
<button onClick={setOn}>Switch On</button>
<Switch on={on} onClick={toggle} />
</div>
)
}
function App() {
return <Toggle />
}
ReactDOM.render(<App />, document.getElementById('root'))
이제 사용자가 "Reset" 버튼을 클릭하지 않으면 사용자는 `<Switch />`를 4번 이상 클릭할 수 없도록 `<Toggle />` 컴포넌트를 변경해야 한다고 가정하겠습니다.
function Toggle() {
const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
const tooManyClicks = clicksSinceReset >= 4
const {on, toggle, setOn, setOff} = useToggle()
function handleClick() {
toggle()
setClicksSinceReset(count => count + 1)
}
return (
<div>
<button onClick={setOff}>Switch Off</button>
<button onClick={setOn}>Switch On</button>
<Switch on={on} onClick={handleClick}/>
{
tooManyClicks ? (
<button onClick={() => setClicksSinceReset(0)}>Reset</button>
) : null
}
</div>
)
}
좋습니다. 이 문제의 간단한 해결책은 `handleClick` 함수에 if문을 추가하고 `tooManyClicks`가 true일때 `toggle`을 호출하지 않는 것이 될 수 있습니다. 하지만 이 예제의 목적을 위해 keep going 하겠습니다.
이 상황에 제어의 역전을 위해 `useToggle` 훅을 어떻게 변경할 수 있을까요? API에 대해 먼저 생각해 보겠습니다. 그 다음이 구현입니다. 사용자 입장에서 실제로 발생하고 수정하기 전에 모든 상태 업데이트에서 훅이 동작할 수 있다면 좋을 것입니다.
function Toggle() {
const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
const tooManyClicks = clicksSinceReset >= 4
const {on, toggle, setOn, setOff} = useToggle({
modifyStateChange(currentState, changes) {
if (tooManyClicks) {
// other changes are fine, but on needs to be unchanged
return {...changes, on: currentState.on}
} else {
// the changes are fine
return changes
}
}
})
function handleClick() {
toggle()
setClicksSinceReset(count => count + 1)
}
return (
<div>
<button onClick={setOff}>Switch Off</button>
<button onClick={setOn}>Switch On</button>
<Switch on={on} onClick={handleClick}/>
{
tooManyClicks ? (
<button onClick={() => setClicksSinceReset(0)}>Reset</button>
) : null
}
</div>
)
}
`<Switch />`가 상태를 변화 시키는 것을 예방하기만을 원하고 "Switch Off" 혹은 "Switch On" 버튼을 클릭했을때를 눌렀을때의 동작을 막는것을 제외하고 괜찮아 보입니다.
음.... `modifyStateChange`를 `reducer`를 이용해 변화시키고 두번째 인자로 `action`을 받으면 어떨까요? `action`은 어떠한 변화가 일어나야 하는지 결정할 수 있는 `type`을 가질수 있고 `useToggle` 훅으로부터 export 될수 있는 `toggleReducer`로 부터 `changes`를 가질 수 있습니다. 스위치를 클릭하는 `type`은 `TOGGLE` 이라 하겠습니다.
function Toggle() {
const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
const tooManyClicks = clicksSinceReset >= 4
const {on, toggle, setOn, setOff} = useToggle({
reducer(currentState, action) {
const changes = toggleReducer(currentState, action)
if (tooManyClicks && action.type === 'TOGGLE') {
// other changes are fine, but on needs to be unchanged
return {...changes, on: currentState.on}
} else {
// the changes are fine
return changes
}
}
})
function handleClick() {
toggle()
setClicksSinceReset(count => count + 1)
}
return (
<div>
<button onClick={setOff}>Switch Off</button>
<button onClick={setOn}>Switch On</button>
<Switch on={on} onClick={handleClick}/>
{
tooManyClicks ? (
<button onClick={() => setClicksSinceReset(0)}>Reset</button>
) : null
}
</div>
)
}
좋습니다! 이것은 우리에게 모든 종류의 제어를 제공해 줍니다. 마지막 한가지는 `TOGGLE` 문자열을 사용하지 않는 것입니다. 대신 사용자가 대신 참조할 수 있는 모든 change type들을 가진 객체를 사용하겠습니다. 이것은 오타를 줄여주고 editor 자동완성을 향상시켜줍니다.
function Toggle() {
const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
const tooManyClicks = clicksSinceReset >= 4
const {on, toggle, setOn, setOff} = useToggle({
reducer(currentState, action) {
const changes = toggleReducer(currentState, action)
if (tooManyClicks && action.type === actionTypes.toggle) {
// other changes are fine, but on needs to be unchanged
return {...changes, on: currentState.on}
} else {
// the changes are fine
return changes
}
}
})
function handleClick() {
toggle()
setClicksSinceReset(count => count + 1)
}
return (
<div>
<button onClick={setOff}>Switch Off</button>
<button onClick={setOn}>Switch On</button>
<Switch on={on} onClick={handleClick}/>
{
tooManyClicks ? (
<button onClick={() => setClicksSinceReset(0)}>Reset</button>
) : null
}
</div>
)
}
Implementing a State Reducer with Hooks
좋습니다. 여기에서 노출되고 있는 API에 저는 만족합니다. `useToggle` 훅을 어떻게 구현할 수 있는지 살펴보겠습니다. 잊어버렸을 경우를 대비하여 아래에 준비하였습니다 :
function useToggle() {
const [on, setOnState] = React.useState(false)
const toggle = () => setOnState(o => !o)
const setOn = () => setOnState(true)
const setOff = () => setOnState(false)
return {on, toggle, setOn, setOff}
}
이러한 helper 함수에 모두가 로직을 추가할 수 있지만 이 과정은 넘어가고 이것은 단순한 훅일지라도 번거라운 일이라고 언급하려 합니다. 대신에 이 형태를 `useStatea`에서 `useReducer`로 재작성하고 구현을 훠얼씬 쉽게 만들어보겠습니다.
function toggleReducer(state, action) {
switch (action.type) {
case 'TOGGLE': {
return {on: !state.on}
}
case 'ON': {
return { on: true }
}
case 'OFF': {
return { on: false }
}
default: {
throw new Error(`Unhandled type: ${action.type}`)
}
}
}
function useToggle() {
const [{on}, dispatch] = React.useReducer(toggleReducer, {on: false})
const toggle = () => dispatch({type: 'TOGGLE'})
const setOn = () => dispatch({type: 'ON'})
const setOff = () => dispatch({type: 'OFF'})
return {on, toggle, setOn, setOff}
}
좋습니다. 매우 빠르네요. 문자열로 전달되는 것을 피하기 위해 `useToggle`에 `types` 속성을 추가하겠습니다. 그리고 훅에서 export 하여 사용자들이 참조할 수 있도록 할겁니다.
const actionTypes = {
toggle: 'TOGGLE',
on: 'ON',
off: 'OFF',
}
function toggleReducer(state, action) {
switch (action.type) {
case actionTypes.toggle: {
return {on: !state.on}
}
case actionTypes.on: {
return { on: true }
}
case actionTypes.off: {
return { on: false }
}
default: {
throw new Error(`Unhandled type: ${action.type}`)
}
}
}
function useToggle() {
const [{on}, dispatch] = React.useReducer(toggleReducer, {on: false})
const toggle = () => dispatch({type: actionsTypes.toggle})
const setOn = () => dispatch({type: actionsType.on})
const setOff = () => dispatch({type: actionTypes.off})
return {on, toggle, setOn, setOff}
}
export {useToggle, actionTypes}
좋습니다. 이제 사용자들은 `useToggle`함수에 configuration 객체로서 `reducer`를 전달할 것입니다. 이걸 적용해 보겠습니다.
function useToggle({reducer}) {
const [{on}, dispatch] = React.useReducer(toggleReducer, {on: false})
const toggle = () => dispatch({type: actionTypes.toggle})
const setOn = () => dispatch({type: actionTypes.on})
const setOff = () => dispatch({type: actionTypes.off})
return {on, toggle, setOn, setOff}
}
좋습니다. 이제 개발자의 `reducer`가 생겼으니 우리의 reducer와 어떻게 결합할 수 있을까요? 사실 우리의 훅에대해 진정으로 제어의 역전을 바랐다면 우리는 우리의 `reducer`를 호출하지 않길 원한다. 대신에 우리의 reducer를 노출하고 그들 스스로 원하는대로 사용할 수 있게 하면 된다. 이걸 export 하자. 우리는 `reducer`를 사용할 거고 그들은 우리 대신에 제공할 겁니다.
function useToggle({reducer}) {
const [{on}, dispatch] = React.useReducer(reducer, {on: false})
const toggle = () => dispatch({type: actionTypes.toggle})
const setOn = () => dispatch({type: actionTypes.on})
const setOff = () => dispatch({type: actionTypes.off})
return {on, toggle, setOn, setOff}
}
export {useToggle, actionTypes, toggleReducer}
좋습니다. 그러나 이제 우리 컴포넌트를 사용하는 모두가 reducer 제공을 원하지 않아도 제공해야 합니다. 제어의 역전을 원하는 사람을 위해 제어 역전이 가능하게 해주고 싶습니다. 그러나 일반적인 경우에는 특별한 작업을 수행할 필요가 없으므로 기본값을 추가하겠습니다 :
function useToggle({reducer = toggleReducer} = {}) {
const [{on}, dispatch] = React.useReducer(reducer, {on: false})
const toggle = () => dispatch({type: actionTypes.toggle})
const setOn = () => dispatch({type: actionTypes.on})
const setOff = () => dispatch({type: actionTypes.off})
return {on, toggle, setOn, setOff}
}
export {useToggle, actionTypes, toggleReducer}
만족스럽네요, 이제 사용자들은 내장되어 있거나 자신의 reducer와 함께 `useToggle`훅을 사용할 수 있습니다. 둘다 잘 동작합니다.
Conclusion
여기 최종 버전이 있습니다.
import * as React from 'react'
import ReactDOM from 'react-dom'
import Switch from './switch'
const actionTypes = {
toggle: 'TOGGLE',
on: 'ON',
off: 'OFF',
}
function toggleReducer(state, action) {
switch (action.type) {
case actionTypes.toggle: {
return {on: !state.on}
}
case actionTypes.on: {
return {on: true}
}
case actionTypes.off: {
return {on: false}
}
default: {
throw new Error(`Unhandled type: ${action.type}`)
}
}
}
function useToggle({reducer = toggleReducer} = {}) {
const [{on}, dispatch] = React.useReducer(reducer, {on: false})
const toggle = () => dispatch({type: actionTypes.toggle})
const setOn = () => dispatch({type: actionTypes.on})
const setOff = () => dispatch({type: actionTypes.off})
return {on, toggle, setOn, setOff}
}
// export {useToggle, actionTypes, toggleReducer}
function Toggle() {
const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
const tooManyClicks = clicksSinceReset >= 4
const {on, toggle, setOn, setOff} = useToggle({
reducer(currentState, action) {
const changes = toggleReducer(currentState, action)
if (tooManyClicks && action.type === actionTypes.toggle) {
// other changes are fine, but on needs to be unchanged
return {...changes, on: currentState.on}
} else {
// the changes are fine
return changes
}
},
})
return (
<div>
<button onClick={setOff}>Switch Off</button>
<button onClick={setOn}>Switch On</button>
<Switch
onClick={() => {
toggle()
setClicksSinceReset(count => count + 1)
}}
on={on}
/>
{tooManyClicks ? (
<button onClick={() => setClicksSinceReset(0)}>Reset</button>
) : null}
</div>
)
}
function App() {
return <Toggle />
}
ReactDOM.render(<App />, document.getElementById('root'))
codesandbox에서의 동작을 확인할 수 있습니다.
기억해야 할, 우리가 여기서 한 것은 사용자가 모든 상태 업데이트를 우리의 reducer로 변화를 만들 수 있게 한것이다. 이건 우리의 hook을 좀 더 유연하게 할것입니다. 하지만 우리가 상태를 업데이트하는 방식이 이제 API의 일부라는 것을 의미하고 이 변화가 일어나는 것을 바꾸려 한다면 사용자들 에게는 큰 변화가 될것입니다. 복잡한 훅/컴포넌트들에는 가치있는 trade-off 이지만 이 점은 명심하는 것이 좋습니다.
여러분이 이 사용성과 같은 패턴을 발견하길 희망합니다. `useReducer` 덕분에, just kinda falls out. 여러분의 코드에 적용해 보세요
Good luck!!
출처 : https://kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks
'Frontend > React' 카테고리의 다른 글
React what is JSX? (번역글) 🤔 (0) | 2022.04.27 |
---|---|
React One Simple trick to optimize React re-renders (번역글) 🤔 (0) | 2022.04.27 |
React theStateReducerPattern (번역글) 🤔 (0) | 2022.04.24 |
React React-query InfiniteQuery 예제 ∞ (0) | 2022.04.23 |
React 글자 4개로 React 컴포넌트 최적화 가능(번역글) 🤔 (0) | 2022.04.20 |