Uncontrolled mode

Uncontrolled mode (비제어 모드)

7.8.0 릴리스에서 도입된 비제어(uncontrolled) 모드는 모든 폼에 권장되는 모드예요. 대형 폼에서 성능을 크게 개선해 주며, 폼 데이터를 React state 대신 ref에 저장해요. 언제 어떻게 쓰는지 하나씩 설명해 드릴게요.

출처: 문서

본문

앞선 예제에서도 볼 수 있듯 제어(controlled) 모드에서는 form.values가 매 변경마다 갱신돼요. 즉 form.values를 사용하는 모든 컴포넌트가 변경될 때마다 다시 렌더링돼요.

비제어 모드

비제어 모드는 7.8.0 릴리스에서 도입된 대체 폼 모드예요. 이제 모든 폼에 권장되는 모드이며, 대형 폼에서 상당한 성능 개선을 제공해요.

비제어 모드에서는 폼 데이터가 React state 대신 ref에 저장되고, form.values는 매 변경마다 갱신되지 않아요.

비제어 모드 폼의 예시예요:

import { useState } from 'react';
import { Button, Code, Text, TextInput } from '@mantine/core';
import { hasLength, isEmail, useForm } from '@mantine/form';

function Demo() {
  const form = useForm({
    mode: 'uncontrolled',
    initialValues: { name: '', email: '' },
    validate: {
      name: hasLength({ min: 3 }, 'Must be at least 3 characters'),
      email: isEmail('Invalid email'),
    },
  });

  const [submittedValues, setSubmittedValues] = useState<typeof form.values | null>(null);

  return (
    <form onSubmit={form.onSubmit(setSubmittedValues)}>
      <TextInput
        {...form.getInputProps('name')}
        key={form.key('name')}
        label="Name"
        placeholder="Name"
      />
      <TextInput
        {...form.getInputProps('email')}
        key={form.key('email')}
        mt="md"
        label="Email"
        placeholder="Email"
      />
      <Button type="submit" mt="md">
        Submit
      </Button>

      <Text mt="md">Form values:</Text>
      <Code block>{JSON.stringify(form.values, null, 2)}</Code>

      <Text mt="md">Submitted values:</Text>
      <Code block>{submittedValues ? JSON.stringify(submittedValues, null, 2) : '–'}</Code>
    </form>
  );
}

위 예제에서 볼 수 있듯 form.values는 전혀 갱신되지 않아요.

form.getValues

form.getValues 함수는 현재 폼 값을 반환해요. 컴포넌트 어디에서든 현재 폼 값을 가져올 때 사용할 수 있고, 제어/비제어 두 모드 모두에서 쓸 수 있어요.

import { useForm } from '@mantine/form';

const form = useForm({
  mode: 'uncontrolled',
  initialValues: { name: 'John Doe' },
});

form.getValues(); // { name: 'John Doe' }

form.setValues({ name: 'John Smith' });
form.getValues(); // { name: 'John Smith' }

제어 모드에서도 form.values로 현재 값을 가져올 수 있지만, form.getValues를 쓰는 걸 권장해요. form.getValues는 항상 최신 값을 반환하는 반면, form.values는 비제어 모드에서 항상 오래된 값이고 제어 모드에서도 state 갱신 이전에는 오래된 값이기 때문이에요.

import { useForm } from '@mantine/form';

const form = useForm({
  mode: 'uncontrolled',
  initialValues: { name: 'John Doe' },
});

const handleNameChange = () => {
  form.setFieldValue('name', 'Test Name');

  // ❌ Do not use form.values to get the current form values
  // form.values has a stale name value until the next rerender in controlled mode
  // and is always outdated in uncontrolled mode
  console.log(form.values); // { name: 'John Doe' }

  // ✅ Use form.getValues to get the current form values
  // form.getValues always returns the latest form values
  console.log(form.getValues()); // { name: 'Test Name' }
};

form.getValues()는 현재 폼 값의 ref 값을 반환해요. 즉 항상 같은 참조(reference)가 반환되므로 useEffect의 의존성 배열에 그대로 전달할 수 없어요.

import { useEffect } from 'react';
import { useForm } from '@mantine/form';

const form = useForm({ mode: 'uncontrolled' });

useEffect(() => {
  // ❌ This will not work as form.getValues() is a ref value
  // and will always be the same reference
}, [form.getValues()]);

useEffect로 폼 값을 관찰하는 대신 onValuesChange 콜백으로 폼 값 변경을 감지해요:

import { useForm } from '@mantine/form';

const form = useForm({
  mode: 'uncontrolled',
  initialValues: { name: 'John Doe' },
  onValuesChange: (values) => {
    // ✅ This will be called on every form value change
    console.log(values);
  },
});

조건부 필드 (Conditional fields)

비제어 모드가 예상 밖으로 동작하는 가장 흔한 경우는 폼 일부를 조건부로 렌더링하는 상황이에요. form.getValues()는 현재 값을 반환하지만 컴포넌트를 변경에 구독시키지 않아요. 값을 한 번만 읽고 컴포넌트는 다시 렌더링되지 않으므로 조건이 절대 갱신되지 않아요:

import { Checkbox, Collapse, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';

function Demo() {
  const form = useForm({
    mode: 'uncontrolled',
    initialValues: { shipsInternationally: false, country: '' },
  });

  // ❌ Does not work: the component is not rerendered when the checkbox is toggled,
  // the collapsed section never opens
  const shipsInternationally = form.getValues().shipsInternationally;

  return (
    <>
      <Checkbox
        label="Ships internationally"
        key={form.key('shipsInternationally')}
        {...form.getInputProps('shipsInternationally', {
          type: 'checkbox',
        })}
      />
      <Collapse expanded={shipsInternationally}>
        <TextInput
          label="Country"
          key={form.key('country')}
          {...form.getInputProps('country')}
        />
      </Collapse>
    </>
  );
}

대신 form.useWatchValue를 사용해요. 단일 필드에 구독하고 그 필드가 변경될 때 컴포넌트를 다시 렌더링해요:

import { Checkbox, Collapse, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';

function Demo() {
  const form = useForm({
    mode: 'uncontrolled',
    initialValues: { shipsInternationally: false, country: '' },
  });

  // ✅ Works: the component is rerendered when the checkbox is toggled
  const shipsInternationally = form.useWatchValue('shipsInternationally');

  return (
    <>
      <Checkbox
        label="Ships internationally"
        key={form.key('shipsInternationally')}
        {...form.getInputProps('shipsInternationally', {
          type: 'checkbox',
        })}
      />
      <Collapse expanded={shipsInternationally}>
        <TextInput
          label="Country"
          key={form.key('country')}
          {...form.getInputProps('country')}
        />
      </Collapse>
    </>
  );
}

form.getInputProps

form.getInputProps는 제어/비제어 모드에서 서로 다른 props를 반환해요. 제어 모드에서는 반환 객체에 value prop이 있고, 비제어 모드에서는 defaultValue prop이 있어요.

비제어 모드는 form.setFieldValue나 form.setValues가 호출될 때 컴포넌트를 갱신하기 위해 form.key()가 반환하는 key에 의존해요. 입력 컴포넌트에 반드시 form.key()가 제공하는 key를 설정해서 갱신된 값을 갖도록 해야 해요:

import { useForm } from '@mantine/form';

function Demo() {
  const form = useForm({
    mode: 'uncontrolled',
    initialValues: { text: '' },
  });

  return (
    <input {...form.getInputProps('text')} key={form.key('text')} />
  );
}

필드 목록이 필요한 경우에는 key를 입력 컴포넌트에 직접 전달하지 마세요. 대신 래퍼 요소를 추가하고 그곳에 key를 전달해요:

import { useForm } from '@mantine/form';
import { randomId } from '@mantine/hooks';

// ❌ Incorrect: Do not override key prop, even in lists
function Demo() {
  const form = useForm({
    mode: 'uncontrolled',
    initialValues: {
      jobs: [{ company: 'Google' }, { company: 'Facebook' }],
    },
  });

  const fields = form.getValues().jobs.map((_, index) => (
      <input
        {...form.getInputProps(`jobs.${index}.company`)}
        key={index}
      />
    ));

  return <form>{fields}</form>;
}

// ✅ Correct: Add a wrapper element and pass key to it
function Demo() {
  const form = useForm({
    mode: 'uncontrolled',
    initialValues: {
      jobs: [
        { company: 'Google', key: randomId() },
        { company: 'Facebook', key: randomId() },
      ],
    },
  });

  const fields = form.getValues().jobs.map((item, index) => (
      <div key={item.key}>
        <input
          {...form.getInputProps(`jobs.${index}.company`)}
          key={form.key(`jobs.${index}.company`)}
        />
      </div>
    ));

  return <form>{fields}</form>;
}

커스텀 컴포넌트에서의 비제어 모드

비제어 폼 모드를 지원하는 커스텀 컴포넌트를 만들려면 defaultValue prop을 지원해야 해요. defaultValue를 지원하는 가장 좋은 방법은 use-uncontrolled 훅을 사용하는 것이에요:

import { useUncontrolled } from '@mantine/hooks';

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

// ✅ CustomInput supports both controlled and uncontrolled modes
function CustomInput({
  value,
  defaultValue,
  onChange,
}: CustomInputProps) {
  const [_value, handleChange] = useUncontrolled({
    value,
    defaultValue,
    finalValue: 'Final',
    onChange,
  });

  return (
    <input
      type="text"
      value={_value}
      onChange={(event) => handleChange(event.currentTarget.value)}
    />
  );
}

function Demo() {
  const form = useForm({
    mode: 'uncontrolled',
    initialValues: { text: 'Initial' },
  });

  // ✅ CustomInput supports `defaultValue` prop,
  // it can be used in uncontrolled mode
  return (
    <CustomInput
      {...form.getInputProps('text')}
      key={form.key('text')}
    />
  );
}

더 알아보기 (Learn more)