재사용 가능한 토글 만들기
- 재사용가능한 컴포넌트 만들기 프로젝트의 첫번째입니다.
- 지금 당장 사용하기 보다는, 추후에 이 컴포넌트를 다시 사용해 다른 스타일을 입혀 빠르게 컴포넌트를 만들 수 있고 저 뿐만이 아니라 팀원도 이 컴포넌트를 쉽게 사용할 수 있도록 만들고 싶었습니다.
- 최종 코드는 여기에서 확인하실 수 있습니다.
기본 기능 정의하기
Toggle
이라는 컴포넌트가 가져야 할 기본적인 기능은 무엇이 있을까 고민해 보았습니다. 우리는 Toggle
을 언제 사용할까요? 제가 Toggle
을 주로 사용했던 부분은 DarkMode
을 on/off 했던 버튼 컴포넌트에서 사용했었습니다.
pressed
라는 상태가true, false
를 통해 전환된다.
그리고, 일반적으로 toggle
을 사용하는 목적은 pressed
의 상태가 변경되어진다면 이를 통한 이벤트를 불러오기 때문이 사용합니다. 그래서 저는 다음과 같은 부분을 기본기능에 추가했습니다.
onPressedChange
: (pressed
:boolean
) ⇒void
상태 정의하기
위의 기본 기능을 구현하기 위해서는 pressed
라는 상태가 필요합니다.
pressed
:boolean
기본적인 Toggle 만들어 보기
위에 정의한, 기능 명세를 이용해 우선 하나의 Toggle
컴포넌트를 만들어보기로 했습니다.
import { useState } from "react"; const Toggle = ( props ) => { const { pressed : pressedState, children } = props; const [pressed, setPressed] = useState(pressedState); return ( <button type='button' onClick={() => setPressed(!pressed)}> {children} </button> ); }; export default Toggle;
이렇게만 만들어도, 기본적인 동작을 잘 작동하는 Toggle
이 만들어집니다. 여기부터 시작해 하나하나 추가해 나가기로 했습니다.
onPressedChange 반영하기
그러니까, 조금 더 실제 사용례를 들어본다면 pressed = true
일 경우에 또는 반대의 경우 특정 이벤트를 실행시키고자 Toggle
버튼을 사용합니다. 그렇기 때문에, 저는 onPressedChange
를 Toggle
컴포넌트의 기본적인 기능이라고 생각했습니다.
function onChange(pressed: Boolean) { console.log(pressed); } // onPressedChange const Toggle = (props: any) => { const { defaultPressed = false, onClick, onPressedChange, disabled } = props; const [pressed = false, setPress] = useState(defaultPressed); useEffect(() => { onPressedChange(pressed); }, [pressed]); return <button type='button' onClick={() => setPress(!pressed)}></button>; }; export default Toggle;
그런데, 이와 같은 방식은 다음 문제점이 있었습니다. React.useEffect
를 통해 바뀌어진 pressed
값을 이용, 해당 함수를 실행합니다. 실행에는 문제가 없지만 state
변경후 렌더링이 된, Toggle
컴포넌트는useEffect
로 무조건 다시 렌더링 됩니다. 그런데, 우리가 원하는 기능은 pressed
상태에 따라 특정 함수를 실행시키는 것 뿐입니다. 따라서 이런 방식으로 컴포넌트를 작성하게 되면 과도한 렌더링만을 발생시키게 될 것이라 예상됩니다.
그리고, 지금 사용하고 있는 이 부분은 추후 다른 컴포넌트를 만들 때에도 중요한 역할을 하게 되는 로직이 될 것 같아 custom hook
으로 작성해야 할 필요성도 있었습니다. 그래서, 아래와 같은 custom hook
을 작성했습니다.
function useControlled(props: any) { const { value, onChange } = props; const [internalState, setInternalState] = React.useState(value); const setState = (next) => { setInternalState(next); onChange?.(next); }; return [internalState, setState]; } // props와 value를 받고, onChange 이벤트를 함께 실행시킬 수 있습니다. // 이를 통해 useEffect의 불필요한 사용을 줄일 수 있었습니다.
Controlled, Uncontrolled
그런데, 사용자 입장에서 이 토글을 어떻게 사용할까요? 일반적으로 두가지 방법으로 사용합니다.
// app.tsx const ToggleWithControlled = () => { const [pressed, setPressed] = React.useState(false); // 외부에 만들어둔 이 pressed 라는 상태를 이용하는 방법 return ( <Toggle pressed={pressed} onPressedChange={(pressed) => setPressed(pressed)} > 토글 </Toggle> ); }; const ToggleWithUncontrolled = () => { // 외부에 상태를 만들지 않고 내부의 상태만을 이용합니다. // 이러한 방식을 통해, form 컨트롤과 navigate 등의 기능을 할 수 있습니다. return ( <Toggle defaultPressed={false} onPressedChange={(pressed) => { if (pressed) console.log(pressed); }} > 토글 </Toggle> ); };
우리는 이 두가지 기능에 모두 대응해야 합니다. 위에 작성한 useControlled
훅을 개선해 진행할 수 있을 것 같습니다. (이 부분은, headless-ui
에서 사용하고 있는 use-controllable
훅을 참고해 진행했습니다. )
function useControlled<T>(props: UseControllabledProps<T>) { const { value: valueProp, defaultValue, onChange } = props; const onChangeProp = useEvent(onChange); const [internalState, setInternalState] = React.useState(defaultValue); const controlled = valueProp !== undefined; // value가 존재하면 해당 컴포넌트는 controlled 모드를 사용한다고 판단합니다. const value = controlled ? valueProp : internalState; const setValue = useEvent((next: React.SetStateAction<T>) => { const setter = next as (prevState?: T) => T; // 이 부분을 하는 이유는 setState(prev => [...prev]) 와 같이 // 매개변수가 함수로 들어올 가능성이 존재하기 때문입니다. const nextValue = typeof next === "function" ? setter(value) : next; if (!controlled) { setInternalState(nextValue); // controlled가 아니라면 내부의 상태를 이용합니다. } onChangeProp(nextValue); // controlled라면 이 부분은 setState Fn이 될 것이고, // unControlled라면 이 부분은 상태를 이용하지 않는 함수가 될 것입니다. }); return [value, setValue] as [T, React.Dispatch<React.SetStateAction<T>>]; }
이러한 내용을 고려해 위의 기본 토글을 다음과 같이 수정했습니다.
import * as React from "react"; import { useControlled } from "../utils"; const Toggle = (props: any) => { const { pressed: pressedState, children, defaultPressed = false, // 아무런 prop없이 사용할 수 있도록 defaultPressed만 기본값을 지정했습니다. // pressed가 들어온다면, defaultPressed는 무시됩니다. onPressedChange, } = props; const [pressed, setPressed] = useControlled({ value: pressedState, defaultValue: defaultPressed, onChange: onPressedChange, }); return ( <button type='button' onClick={() => setPressed(!pressed)}> {children} </button> ); }; export default Toggle;
Render Prop 적용하기
일반적으로, toggle
을 어떻게 사용할 지 마지막 고민을 하던 중 꼭 필요하다고 생각하는 부분이 있었습니다. 우리가 toggle
을 사용할때 일반적으로 pressed
의 상태에 따라 toggle
의 children
에 변화를 주게 됩니다.
위의 예시는 다크모드를 적용하는 토글 버튼인데, 다크모드와 라이트모드의 구분을 주기 위해 button
내부에 다른 string
이 들어가게 됩니다. 이 부분에, 아이콘을 넣을 수도 있고 또는 다른 태그를 넣을 수도 있을 것입니다.
일반적인, 상황에서 toggle
이 pressed
되었음을 상태로 관리하게 되므로 문제없이 사용할 수 있습니다. 그런데, 해당 toggle
은 외부에 상태를 만들지 않고 내부의 상태만으로도 이용 가능하게 작성되어져 있고, 이를 반영할 수 있어야 한다고 생각했습니다.
export type ToggleProps = { pressed?: boolean; defaultPressed?: boolean; onPressedChange?: (pressed: boolean) => void; disabled?: boolean; children?: | React.ReactNode | ((props: { pressed: boolean; disabled: boolean }) => React.ReactNode); // children에 대한 타입 지정을 수정했습니다. }; return ( <button role='button' tabIndex={disabled ? undefined : 0} ... > {typeof children === "function" ? children(renderProps) : children} // children이 함수로 들어오게 되면, renderProps를 매개 변수로 받아 사용합니다. </button> );
위와 같은 방식으로 수정하면, 다음과 같이 render Prop
을 이용할 수 있습니다.
// toggle의 children을 내부의 renderProp을 이용해 // 조건부 렌더링을 진행할 수 있습니다. <Toggle defaultPressed={false}> {({ pressed }) => pressed ? <span>pressed</span> : <span>notPressed</span> } </Toggle>
Type 지정하기
그런데, 이 Toggle
컴포넌트는 기본적으로 button
태그로 동작해야 합니다. 따라서 button
태가가 가질 수 있는 모든 attributes
를 가지고 있어야 할 것입니다. 따라서 다음과 같은 type
을 지정할 수 있습니다.
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>; type ButtonPropsWithoutChildren = Omit<ButtonProps, "children">; // renderProps를 사용하는 부분에서, 오류가 발생해 children을 제거한 타입을 이용했습니다. interface ToggleProps extends ButtonPropsWithoutChildren { pressed?: boolean; defaultPressed?: boolean; onPressedChange?: (pressed: boolean) => void; disabled?: boolean; children?: React.ReactNode | ((props: ToggleRenderProps) => React.ReactNode); }
접근성 고려하기
접근성 관련 항목은, w3c APG
를 참고했습니다. w3c APG
문서에 따르면 button
을 위한 접근성 방법은 다음과 같습니다.
ari-role
은button
입니다.pressed
에 따라aria-pressed
를 지정해주어야 합니다.- 사용이 불가능 하다면
aria-disabled
가true
이어야 합니다. tab
으로 포커스 할 수 있어야 합니다.space, enter
키보드 이벤트를 지원해야 합니다.
aria-attribute 지정하기
aria-attribute
다음과 같이 지정했습니다.
return <button role='button' .... // for accessability aria-pressed={pressed} aria-disabled={disabled} {...restProps} > {children} </button>
기존 pressed
상태를 계속해서 가지고 있었고, disabled
는 props
로 받고 있었으므로 쉽게 적용이 가능했습니다.
keyboard 이벤트
우선, tabIndex
를 신경 써야 합니다. 전역에서 tab
키를 통해 이 토글에 focus
할 수 있어야 하기 때문입니다. 그런데, button
태그만을 사용한다면 tabIndex
를 지정해줄 필요가 없습니다. 기본적으로 tabIndex
를 가지고 있기 때문입니다. 다만, 사용자가 div
태그를 이용한다면 해당 부분에 문제가 생길 수 있습니다. (tabIndex
를 지정하지 않는다면, focus
할 수 없기 때문입니다. )
또한, button
에는 기본적으로 space, enter
키보드 이벤트가 존재합니다. 하지만, 사용자가 다른 태그를 사용할 수 있도록 하였으므로 기본 내장 이벤트는 취소하고 새롭게 달아준 keyDown
이벤트를 사용할 수 있도록 작성했습니다.
export const Toggle = (props: ToggleProps) => { ... const handleClick = () => { if (disabled) return; setPressed(!pressed); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!TOGGLE_KEYS.includes(e.key)) return; e.preventDefault(); handleClick(); }; return ( <button type='button' onClick={handleClick} onKeyDown={handleKeyDown} data-pressed={pressed ? "on" : "off"} data-disabled={disabled ? true : undefined} disabled={disabled ? true : undefined} role='button' tabIndex={disabled ? undefined : 0} aria-pressed={pressed} aria-disabled={disabled} {...restProps} > {typeof children === "function" ? children(renderProps) : children} </button> ); };
회고
단순한 토글 컴포넌트를 작성한다고 생각했을때, 위와 같은 복잡한 로직이 필요하다고 생각하지 않았었습니다. 그런데, 사용자가 이 기능을 어떻게 사용할까? 를 중점적으로 생각하며 진행하다보니 조금조금 구현하고자 하는 기능이 늘어나 조금 복잡한 컴포넌트가 되었습니다.
다만, 이번 토글을 작성하는 동안 renderProps
의 사용방법이나 토글 버튼의 접근성과 관련해 학습할 수 있었습니다. 해당 부분은 다음에, 다른 방식으로도 유용하게 사용할 수 있을 것 같습니다.