Post Detail Page

tech

Storybook X react-hook-form 연동 이슈 해결하기

Cannot destructure property 'register' of 'formMethod' as it is undefined.

1️⃣ 문제 상황

다른 컴포넌트들은 정상적으로 동작하지만, react-hook-form이 적용된 모든 컴포넌트에서 오류가 발생했다. formMethod 자체가 undefined이기 때문에 register를 구조 분해 할당할 수 없다는 것

mds-storybook-image
mingle-ui | Input, InputField 스토리북 오류

2️⃣ 오류 내용 확인

☹︎ ERROR
The component failed to render properly, likely due to a configuration issue in Storybook. Here are some common causes and how you can address them:

Missing Context/Providers: You can use decorators to supply specific contexts or providers, which are sometimes necessary for components to render correctly. For detailed instructions on using decorators, please visit the Decorators documentation.

Misconfigured Webpack or Vite: Verify that Storybook picks up all necessary settings for loaders, plugins, and other relevant parameters. You can find step-by-step guides for configuring Webpack or Vite with Storybook.

Missing Environment Variables: Your Storybook may require specific environment variables to function as intended. You can set up custom environment variables as outlined in the Environment Variables documentation.

오류 내용을 보면 몇 가지 일반적인 원인과 해결방안을 공식문서로 안내하고 있다.

  • Context/Providers 누락
    👉 Decorators를 사용하여 특정 contexts 또는 providers를 제공

  • Webpack or Vite 잘못된 구성
    👉 각 빌드별 세팅 확인

  • 누락된 환경 변수
    👉 환경 변수 확인


3️⃣ Vite - Setup

스토리북을 설정할 때 각 번들러마다 추가 설정이 필요한데 나는 vite를 사용해서 프로젝트를 만들었기 때문에 vite 관련 설정이 필요하다. 초기 프로젝트 세팅 때 이미 해당 설정들을 적용했으므로 관련된 오류는 아니었다.

✅ 빌더 설치

npm install @storybook/builder-vite --save-dev

✅ 빌더를 포함하도록 main.ts 업데이트

// .storybook > main.ts

export default {
  stories: ['../src/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
  core: {
    builder: '@storybook/builder-vite', // 👈 추가하는 부분
  },
};

4️⃣ 해결 방안 1

mds-storybook-image
Component Story Format (CSF)

스토리에 render 메서드를 직접 정의하여 컴포넌트를 렌더링하는 방법을 적용했다.
render 내부에 useForm을 호출하여 FormProvider를 통해 Input 컴포넌트로 전달하는 방식으로
스토리마다 고유의 render 메서드를 사용하여 컴포넌트를 적용할 수 있다.

const meta: Meta<typeof Input> = {
  // ...
}

export default meta;
type Story = StoryObj<typeof meta>;

export const Email: Story = {
  render: (args) => {
    const methods = useForm();
    return (
      <FormProvider {...methods}>
        <Input {...args} formMethod={methods} />
      </FormProvider>
    );
  },
  args: {
    id: 'email',
    name: 'email',
    type: 'email',
    placeholder: 'Email',
    errorMessage: 'Email is required',
    isRequired: true,
  },
};

// 📌 Email과 동일하게 render 메서드를 직접 정의해야 합니다.
export const Password: Story = {
  args: {
    id: 'password',
    name: 'password',
    type: 'password',
    placeholder: 'Password',
    errorMessage: 'Password is required',
    isRequired: true,
  },
};

위 방법으로 오류를 해결할 수 있는데, Password 스토리에도 Email 스토리에 render를 적용한 것과 동일하게 작성해야하기 때문에 중복 코드가 발생할 수 있어 공통으로 사용하는 템플릿 컴포넌트를 만들어 개선해볼 수 있다.(해결 방안 2 참고)


5️⃣ 해결 방안 2

🔗 react-hook-form github 참고

같은 오류를 겪은 사람들이 이미 GitHub 이슈에 글을 작성했을 가능성이 높다😀 반드시 확인해 볼 것!

mds-storybook-image

두 번째 해결 방법으로 react-hook-form의 discussion에서 찾았고, 이를 바탕으로 작성한 코드다.
공통 Template이라는 함수 컴포넌트를 정의하여 공통된 렌더링 로직을 작성하고 Template.bind({})
사용하여 비슷한 구조를 가진 여러 스토리를 정의할 수 있다.

const meta: Meta<typeof Input> = {
  // ...
}

export default meta;
type Story = StoryObj<typeof meta>;

const Template = (args) => {
  const methods = useForm();
  return (
    <FormProvider {...methods}>
      <Input {...args} formMethod={methods} />
    </FormProvider>
  );
};

export const Email: Story = Template.bind({});
Email.args = {
  id: 'email',
  name: 'email',
  type: 'email',
  placeholder: 'Email',
  errorMessage: 'Email is required',
  isRequired: true,
};

export const Password: Story = Template.bind({});
Password.args = {
  id: 'password',
  name: 'password',
  type: 'password',
  placeholder: 'Password',
  errorMessage: 'Password is required',
  isRequired: true,
};

6️⃣ 해결 방안 3

Decorators
When writing stories, decorators are typically used to wrap stories with extra markup or context mocking.

공식문서에 따르면 Decorators는 스토리를 추가 렌더링할 수 있는 기능으로, 마크업이나 컨텍스트로 래핑할 수 있다고 나와있다.

이 방법은 PrimaryButton 컴포넌트의 width 값을 w-full로 지정해도 스토리북의 preview에서는 반영되지 않는 문제를 해결하기 위해 사용되었었다.

📂 stories > PrimaryButton.stories.tsx

const meta = {
  title: 'Buttons/PrimaryButton',
  component: PrimaryButton,
  parameters: {
    layout: 'centered',
  },
  decorators: (Story) => (
    <div style={{ width: '787px', display: 'flex', justifyContent: 'center' }}>
      <Story />
    </div>
  ),
  tags: ['autodocs'],

  // ...
};

☻ Decorators 사용 시 주의사항
파일 확장자를 ts에서 tsx로 변경해야 한다.


Decorators를 사용하여 모든 스토리에 공통의 methods를 전달하기 위해 FormProvider로 Story를 감싸고, useForm 훅을 사용하여 생성된 methods를 formMethod prop으로 전달했지만 여전히 formMethodundefined인 상태였다.

내가 따로 추가한 formMethod가 기본 args에 포함되지 않아 undefined 오류가 발생하는 것으로 파악되었다.

const meta: Meta<typeof Input> = {
  // ...
  decorators: [
    (Story) => {
      const methods = useForm();
      return (
        <FormProvider {...methods}>  // FormProvider {...useForm()}도 안 됨❌
          <Story formMethod={methods} />
        </FormProvider>
      );
    },
  ],
  // ...
} satisfies Meta<typeof Input>;

“Context” for mocking

mds-storybook-image
story context

데코레이터 함수는 Storycontext를 인수로 받을 수 있다. 여기서 context는 두 번째 인자로 각 스토리에 전달하는 프로퍼티를 포함하고 있다.(args, argTypes, globals, hooks, parameters 등)

decorators: [
  (Story, context) => {
    const methods = useForm();
    return (
      <FormProvider {...methods}>
        <Story args={{ ...context.args, formMethod: methods }} />
      </FormProvider>
    );
  },
]

데코레이터 내에서 기존의 args에 formMethod와 같은 추가적인 props를 스토리에 전달해주면 오류가 해결된다. 모든 스토리에 공통적으로 useForm과 FormProvider를 적용하면서 각 스토리에 필요한 props를 쉽게 전달할 수 있게 되었다.

📂 Input.stories.tsx

import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { useForm, FormProvider } from 'react-hook-form';
import Input from '../src/components/Input';

const meta: Meta<typeof Input> = {
  title: 'InputField/Input',
  component: Input,
  parameters: {
    layout: 'centered',
  },
  decorators: [
    (Story, context) => {
      const methods = useForm();
      return (
        <FormProvider {...methods}>
          <Story args={{ ...context.args, formMethod: methods }} />
        </FormProvider>
      );
    },
  ],
  tags: ['autodocs'],

  argTypes: {
    name: {
      control: 'text',
      description: 'Input의 name',
    },
    type: {
      control: 'text',
      description: 'Input의 type',
    },
    formMethod: {
      control: 'object',
      description: 'react-hook-form의 메소드',
    },
    placeholder: {
      control: 'text',
      description: 'Input의 placeholder',
    },
    errorMessage: {
      control: 'text',
      description: '에러 메시지',
    },
    isRequired: {
      control: 'boolean',
      description: '필수 요소 유무',
    },
  },
} satisfies Meta<typeof Input>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Email: Story = {
  args: {
    id: 'email',
    name: 'email',
    type: 'email',
    placeholder: 'Email',
    errorMessage: 'Email is required',
    isRequired: true,
  },
};

export const Password: Story = {
  args: {
    id: 'password',
    name: 'password',
    type: 'password',
    placeholder: 'Password',
    errorMessage: 'Password is required',
    isRequired: true,
  },
};
mds-storybook-image
mingle-ui | Story 오류 해결