📝

MUIのSelectをちょっとだけ使いやすくする

2023/09/11に公開

概要

@mui/materialSelectTextField を一緒に使う場面が多かったため、 TextField に構造を寄せる形で使い回しが利くようにしました。

まずはそのまま使ってみる

勿論そのまま使っても問題なく動きますが、

TextField

  • FormControl
  • InputLabel
  • Input
  • FormHelperText

から構成されているのに対し、 Select は labelやhelp textを持ちません。そのため TextField と同じように振る舞わせてあげる場合は以下のように実装する必要があります。

import {
  Container,
  FormControl,
  FormHelperText,
  InputLabel,
  MenuItem,
  Select,
  SelectChangeEvent,
  Stack,
  TextField,
} from '@mui/material';
import { ChangeEvent, FC, useState } from 'react';

export const SampleComponent: FC = () => {
  const [name, setName] = useState('');
  const [age, setAge] = useState('');

  const [textFieldError, setTextFieldError] = useState<string>();
  const [selectError, setSelectError] = useState<string>();

  const handleTextFieldChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
    setName(event.target.value);
  };

  const handleSelectChange = (event: SelectChangeEvent): void => {
    setAge(event.target.value as unknown as string);
  };

  return (
    <Container>
      <Stack spacing={4}>
        {/* TextFieldはこれだけでいい */}
        <TextField
          id="demo-simple-text-field-outlined"
          label="Name"
          onChange={handleTextFieldChange}
          value={name}
          error={!!textFieldError}
          helperText={textFieldError}
        ></TextField>

        {/* Selectはlabelやhelp textを自分で書く必要がある */}
        <FormControl error={!!selectError}>
          <InputLabel id="demo-simple-select-outlined-label">Age</InputLabel>
          <Select
            labelId="demo-simple-select-outlined-label"
            id="demo-simple-select-outlined"
            value={age}
            label="Age"
            onChange={handleSelectChange}
          >
            <MenuItem value="">
              <em>None</em>
            </MenuItem>
            <MenuItem value={10}>Ten</MenuItem>
            <MenuItem value={20}>Twenty</MenuItem>
            <MenuItem value={30}>Thirty</MenuItem>
          </Select>
          {!!selectError && <FormHelperText>{selectError}</FormHelperText>}
        </FormControl>
      </Stack>
    </Container>
  );
};

FormControl + SelectのWrapper Componentを作る

それでは上記の実装をスッキリさせるために FormControlSelectを組み合わせたWrapper Componentを作ります。TextField に寄せて SelectField という名前にします。
共通化する上で不要になるPropsは事前に削っておきます。各プロパティの適用されるコンポーネントを明確にするためにPropsの中に SelectProps を作っていますが、不要であればフラットにしてしまっても大丈夫です。(プロダクトで使う場合はもっと絞られる想定)

import { FormControl, FormControlProps, FormHelperText, InputLabel, Select, SelectProps } from '@mui/material';
import { FC, ReactNode, useMemo } from 'react';

interface Props extends Omit<FormControlProps, 'children' | 'id' | 'onChange'> {
  helperText?: ReactNode;
  label?: ReactNode;
  SelectProps?: Omit<SelectProps, 'label' | 'labelId' | 'variant'>;
}

export const SelectField: FC<Props> = ({ helperText, label, SelectProps, ...props }) => {
  const labelId = useMemo(
    () => (SelectProps?.id !== undefined ? `${SelectProps?.id}-label` : undefined),
    [SelectProps?.id]
  );

  return (
    <FormControl {...props}>
      <InputLabel id={labelId}>{label}</InputLabel>
      <Select
        {...SelectProps}
        id={SelectProps?.id}
        labelId={labelId}
        label={label}
        value={SelectProps?.value}
        onChange={SelectProps?.onChange}
      >
        {SelectProps?.children}
      </Select>
      {!!helperText && <FormHelperText>{helperText}</FormHelperText>}
    </FormControl>
  );
};

これにより元の実装は以下のようになります。 form control, label, help textが無くなって若干スッキリしましたね。

import { Container, MenuItem, SelectChangeEvent, Stack, TextField } from '@mui/material';
import { ChangeEvent, FC, useState } from 'react';
import { SelectField } from '../select-field/SelectField';

export const SampleComponent: FC = () => {
  const [name, setName] = useState('');
  const [age, setAge] = useState('');

  const [textFieldError, setTextFieldError] = useState<string>();
  const [selectError, setSelectError] = useState<string>();

  const handleTextFieldChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
    setName(event.target.value);
  };

  const handleSelectChange = (event: SelectChangeEvent<unknown>): void => {
    setAge(event.target.value as string);
  };

  return (
    <Container>
      <Stack spacing={4}>
        <TextField
          id="demo-simple-text-field-outlined"
          label="Name"
          onChange={handleTextFieldChange}
          value={name}
          error={!!textFieldError}
          helperText={textFieldError}
        ></TextField>

        <SelectField
          error={!!selectError}
          label="Age"
          helperText={selectError}
          SelectProps={{
            id: 'demo-simple-select-outlined',
            onChange: handleSelectChange,
            value: age,
            children: [
              <MenuItem key="none" value="">
                <em>None</em>
              </MenuItem>,
              <MenuItem key="ten" value={10}>
                Ten
              </MenuItem>,
              <MenuItem key="twenty" value={20}>
                Twenty
              </MenuItem>,
              <MenuItem key="thirty" value={30}>
                Thirty
              </MenuItem>,
            ],
          }}
        ></SelectField>
      </Stack>
    </Container>
  );
};

補足: forwardRefを付ける場合

react-hook-form 等を使う場合は子コンポーネント(今回だとselect)に参照を渡せないとエラーになってしまいます。
なので SelectField 内で forwardRef で参照を渡すようにしておきます。実装は以下になります。

import { FormControl, FormControlProps, FormHelperText, InputLabel, Select, SelectProps } from '@mui/material';
import { FC, ReactNode, forwardRef, useMemo } from 'react';

interface Props extends Omit<FormControlProps, 'children' | 'id' | 'onChange'> {
  helperText?: ReactNode;
  label?: ReactNode;
  SelectProps?: Omit<SelectProps, 'label' | 'labelId' | 'ref' | 'variant'>;
}

const SelectField: FC<Props> = forwardRef<HTMLDivElement, Props>(
  ({ helperText, label, SelectProps, ...props }, ref) => {
    const labelId = useMemo(
      () => (SelectProps?.id !== undefined ? `${SelectProps?.id}-label` : undefined),
      [SelectProps?.id]
    );

    return (
      <FormControl {...props}>
        <InputLabel id={labelId}>{label}</InputLabel>
        <Select
          {...SelectProps}
          id={SelectProps?.id}
          labelId={labelId}
          label={label}
          value={SelectProps?.value}
          onChange={SelectProps?.onChange}
          ref={ref}
        >
          {SelectProps?.children}
        </Select>
        {!!helperText && <FormHelperText>{helperText}</FormHelperText>}
      </FormControl>
    );
  }
);

SelectField.displayName = 'SelectField';

export { SelectField };

まとめ

これで TextField と同じ感覚で Select を使えるようになりました。MUIはカスタマイズ無しでもかなり使い勝手の良いUIフレームワークなので今後も使い倒していきます。

Discussion