복잡한 하위 컴포넌트에서 상태 가져오기의 Golden Pattern

ddoachi·2025년 4월 26일

TekaPicker

목록 보기
12/30

의도

  • 하나의 복잡한 컴포넌트가 있고
  • 그 내부에 여러 요소(필드, 토글, 셀렉트 등)가 있다
  • 이 컴포넌트 "외부"에서 내부 값들을 깨끗하게 가져오고 싶다
  • 그럴 때 가장 좋은 패턴은?

골든 패턴

복잡한 컴포넌트 안쪽에 여러 필드가 있을 때
외부에서 이 컴포넌트의 상태를 가져오는 가장 좋은 방법은?

✅ react-hook-form + Controller 를 사용해서
✅ 컴포넌트 전체를 하나의 Controlled 컴포넌트로 만드는 것

그림으로 나타내면 ..

부모 Form
 └ 복잡한 컴포넌트 (Controlled 컴포넌트)
     ├ TextField
     ├ Select
     ├ ToggleButtonGroup
     └ 기타 입력 요소들

코드 구조 예시

1. 부모 (상위 폼) 컴포넌트

import { useForm, Controller } from 'react-hook-form';
import ComplexFormGroup from './ComplexFormGroup';

interface ParentFormValues {
  group: {
    name: string;
    category: string;
    coin: number;
  };
}

function ParentForm() {
  const { control, handleSubmit } = useForm<ParentFormValues>({
    defaultValues: {
      group: {
        name: '',
        category: '',
        coin: 1,
      },
    },
  });

  const onSubmit = (data: ParentFormValues) => {
    console.log('Form 전체 데이터:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller    // 주목!!
        name="group"
        control={control}
        render={({ field }) => (
          <ComplexFormGroup
            value={field.value}
            onChange={field.onChange}
          />
        )}
      />

      <button type="submit">제출</button>
    </form>
  );
}

하위 (복잡한 컴포넌트) ComplexFormGroup

interface ComplexFormGroupProps {
  value: {
    name: string;
    category: string;
    coin: number;
  };
  onChange: (value: { name: string; category: string; coin: number }) => void;
}

function ComplexFormGroup({ value, onChange }: ComplexFormGroupProps) {
  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onChange({ ...value, name: e.target.value });
  };

  const handleCategoryChange = (e: React.ChangeEvent<{ value: unknown }>) => {
    onChange({ ...value, category: e.target.value as string });
  };

  const handleCoinChange = (e: React.MouseEvent<HTMLElement>, newCoin: number | null) => {
    if (newCoin !== null) {
      onChange({ ...value, coin: newCoin });
    }
  };

  return (
    <>
      <TextField
        label="메뉴명"
        value={value.name}
        onChange={handleNameChange}
      />

      <Select
        value={value.category}
        onChange={handleCategoryChange}
      >
        {/* 메뉴 아이템 뿌리기 */}
      </Select>

      <ToggleButtonGroup
        value={value.coin}
        exclusive
        onChange={handleCoinChange}
      >
        <ToggleButton value={1}>1</ToggleButton>
        <ToggleButton value={2}>2</ToggleButton>
        <ToggleButton value={4}>4</ToggleButton>
      </ToggleButtonGroup>
    </>
  );
}

하위 component가 따로 없이 간단한 구조일 때의 예시

import { useForm, Controller } from 'react-hook-form';
import { Button, Dialog, DialogActions, DialogContent, TextField } from '@mui/material';
import CoinToggleGroup from '@/apps/menu/components/CoinToggleGroup';
import CategorySelect from '@/apps/menu/components/CategorySelect';

interface CreateMenuFormValues {
  menuName: string;
  category: string;
  coin: number;
}

function CreateMenuDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
  const { register, control, handleSubmit } = useForm<CreateMenuFormValues>({
    defaultValues: {
      menuName: '',
      category: '',
      coin: 1,
    },
  });

  const onSubmit = (data: CreateMenuFormValues) => {
    console.log('Form Data:', data);
    onClose();
  };

  return (
    <Dialog open={open} onClose={onClose}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
          {/* 메뉴명 */}
          <TextField
            {...register('menuName')}
            placeholder="메뉴명"
            size="small"
            fullWidth
          />

          {/* 카테고리 */}
          <TextField
            {...register('category')}
            select
            label="카테고리"
            fullWidth
            size="small"
          >
            {/* 여기에 MenuCategoryKeys.map으로 MenuItem 뿌리면 됨 */}
          </TextField>

          {/* 코인 선택 */}
          <Controller
            name="coin"
            control={control}
            render={({ field }) => (
              <CoinToggleGroup
                value={field.value}
                onChange={(_, newValue) => field.onChange(newValue)}
              />
            )}
          />
        </DialogContent>

        <DialogActions>
          <Button onClick={onClose}>취소</Button>
          <Button type="submit">확인</Button>
        </DialogActions>
      </form>
    </Dialog>
  );
}

export default CreateMenuDialog;

내 경우에는..?

MealMenus.tsx (상위 컴포넌트)

import { useForm } from 'react-hook-form';
import CreateMenuDialog from './CreateMenuDialog';
import { CreateMenuFormValues } from '@/apps/menu/types/menuForm';

function MealMenus() {
  const { control, handleSubmit } = useForm<CreateMenuFormValues>({
    defaultValues: {
      menuName: '',
      category: '',
      coin: 1,
    },
  });

  const handleCreateMenu = (data: CreateMenuFormValues) => {
    console.log('추가할 메뉴:', data);
    // 서버에 전송하거나 상태 업데이트
  };

  return (
    <>
      {/* 메뉴 목록 */}
      {/* Add 버튼 */}
      <CreateMenuDialog
        control={control}
        onSubmit={handleSubmit(handleCreateMenu)}
      />
    </>
  );
}

CreateMenuDialog.tsx (하위컴포넌트)

import { Control, Controller } from 'react-hook-form';
import { CreateMenuFormValues } from '@/apps/menu/types/menuForm';

interface CreateMenuDialogProps {
  control: Control<CreateMenuFormValues>;
  onSubmit: () => void; // react-hook-form의 handleSubmit로 넘겨받음
}

function CreateMenuDialog({ control, onSubmit }: CreateMenuDialogProps) {
  return (
    <Dialog open>
      <form onSubmit={onSubmit}>
        <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
          <Controller
            name="menuName"
            control={control}
            render={({ field }) => (
              <TextField {...field} placeholder="메뉴명" size="small" fullWidth />
            )}
          />
          <Controller
            name="category"
            control={control}
            render={({ field }) => (
              <Select {...field} label="카테고리" fullWidth size="small">
                {/* MenuCategoryKeys.map */}
              </Select>
            )}
          />
          <Controller
            name="coin"
            control={control}
            render={({ field }) => (
              <CoinToggleGroup
                value={field.value}
                onChange={(_, newCoin) => field.onChange(newCoin)}
              />
            )}
          />
        </DialogContent>

        <DialogActions>
          <Button type="submit">확인</Button>
        </DialogActions>
      </form>
    </Dialog>
  );
}
profile
내일도 풀스택

0개의 댓글