OTTER-LOG

재사용 가능한 토글 만들기

재사용 가능한 토글 만들기
by otter2022년 12월 8일에 최종수정되었습니다.
잘못된 내용이 있으면 댓글을 달아주세요.
  • 재사용가능한 컴포넌트 만들기 프로젝트의 첫번째입니다.
  • 지금 당장 사용하기 보다는, 추후에 이 컴포넌트를 다시 사용해 다른 스타일을 입혀 빠르게 컴포넌트를 만들 수 있고 저 뿐만이 아니라 팀원도 이 컴포넌트를 쉽게 사용할 수 있도록 만들고 싶었습니다.
  • 최종 코드는 여기에서 확인하실 수 있습니다.

기본 기능 정의하기

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 버튼을 사용합니다. 그렇기 때문에, 저는 onPressedChangeToggle 컴포넌트의 기본적인 기능이라고 생각했습니다.

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 의 상태에 따라 togglechildren 에 변화를 주게 됩니다.

위의 예시는 다크모드를 적용하는 토글 버튼인데, 다크모드와 라이트모드의 구분을 주기 위해 button 내부에 다른 string 이 들어가게 됩니다. 이 부분에, 아이콘을 넣을 수도 있고 또는 다른 태그를 넣을 수도 있을 것입니다.

일반적인, 상황에서 togglepressed 되었음을 상태로 관리하게 되므로 문제없이 사용할 수 있습니다. 그런데, 해당 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 을 위한 접근성 방법은 다음과 같습니다.

  1. ari-rolebutton 입니다.
  2. pressed 에 따라 aria-pressed 를 지정해주어야 합니다.
  3. 사용이 불가능 하다면 aria-disabledtrue 이어야 합니다.
  4. tab 으로 포커스 할 수 있어야 합니다.
  5. space, enter 키보드 이벤트를 지원해야 합니다.

aria-attribute 지정하기

aria-attribute 다음과 같이 지정했습니다.

return <button role='button' .... // for accessability aria-pressed={pressed} aria-disabled={disabled} {...restProps} > {children} </button>

기존 pressed 상태를 계속해서 가지고 있었고, disabledprops 로 받고 있었으므로 쉽게 적용이 가능했습니다.

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> ); };