🐥

【React】フィルタリングできるテーブルフォームを実装する

2022/11/29に公開

つくったもの

useStateでテーブル全体のstateを管理するテーブルフォームを実装しようとしたのですが、テーブルが大きくなるとフォームの入力に対して再描画が追いつかず使い物になりませんでした。
対応として、stateの管理をuseStateからReact-Hook-Formに移行しました。
ついでにフィルタリング機能もつけました。UIコンポーネントはMaterial-UI(v4)を使用しています。
(下のgifは再生速度が遅いですが実物はもっとサクサク動きます)

コード

細かい実装内容は端折って、CSSの設定も割愛します。

Tableの実装

Table.tsx
import InputBase from '@material-ui/core/InputBase';
import FilterListIcon from '@material-ui/icons/FilterList';
import { Popover } from './Popover';
import type { CheckBoxItem } from './CheckBoxItem';
import { Controller, useFieldArray, useForm } from 'react-hook-form';

type FormValues = {
  tableItems: {
    text: string;
    col1: string;
    col2: string;
  }[]
};
const items = [
  {key: 'key_1', label: 'A'},
  {key: 'key_2', label: 'B'},
  {key: 'key_3', label: 'AB'}
]
const initialCheckBoxItems = items.map((item) => ({
  key: item.key,
  label: item.label,
  checked: true,
  disabled: false,
  onStateChange: () => {}
})),

export const Table = () => {
  const { data: tableItems } = useItems();
  const { control } = useForm<FormValues>(
    defaultValues: { tableItems }
  );
  const { fields } = useFieldArray({
    name: 'tableItems',
    control
  });
  
  const [checkBoxItems, setCheckBoxItems] = useState<CheckBoxItem[]>(
    initialCheckBoxItems
  );
  const checkedItems = checkBoxItems
    .filter((item) => item.checked)
    .map((item) => item.label);
  const popoverRef = useRef(null);
  const [anchorEl, setAnchorEl] = useState(null);
  return (
    <>
      <Popover
        initialItems={initialCheckBoxItems}
        items={checkBoxItems}
        setItems={setCheckBoxItems}
        searchPlaceholder={'Search'}
        checkBoxListLabel={'All items'}
        onPopoverClose={() => {
          setAnchorEl(null);
        }}
        anchorEl={anchorEl}
      />
      <Table stickyHeader>
        <TableHead>
          <TableRow>
            <HeaderCell text={'TextInput'} />
            <HeaderCell text={'Column_1'} />
            <HeaderCell
              ref={popoverRef}
              text={'Column_2'}
              badgeCount={checkedItems.length}
              onBadgeClick={() => {}}
              icon={
                <FilterListIcon
                  onClick={() => {
                    setAnchorEl(popoverRef.current);
                  }}
                />
              }
            />
          </TableRow>
        </TableHead>
        <TableBody>
          {fields.map((field, index) => (
            <>
              {checkedItems.includes(field.col2) && (
                <TableRow
                  hover
                  key={field.id}
                >
                  <Controller
                    name={`field.${index}.text`}
                    control={control}
                    render={({ field }) => {
                      return (
                        <InputBase
                          placeholder={'placeholder'}
                          value={field.value}
                          onChange={field.onChange}
                        />
                      );
                    }}
                  />
                  <TextCell text={field.col1} />
                  <TextCell text={fiels.col2} />
                </TableRow>
              )}
            </>
          ))}
        </TableBody>
      </Table>
    </>
  );
};

Popoverの実装

Popover.tsx
import Paper from '@material-ui/core/Paper';
import Grid from '@material-ui/core/Grid';
import Divider from '@material-ui/core/Divider';
import InputBase from '@material-ui/core/InputBase';
import MuiPopover from '@material-ui/core/Popover';
import { CheckBoxList } from './CheckBoxList';
import type { CheckBoxItem } from './CheckBoxItem';

type Props = {
  searchPlaceholder: string;
  checkBoxListLabel: string;
  initialItems: CheckBoxItem[];
  items: CheckBoxItem[];
  setItems: React.Dispatch<React.SetStateAction<CheckBoxItem[]>>;
  onPopoverClose: () => void;
  anchorEl: Element | null;
};

const filterItems = (
  items: CheckBoxItem[],
  filterText: string
): CheckBoxItem[] => {
  if (filterText == '') return items;
  return items.filter((item) => item.label.includes(filterText));
};

export const Popover = ({
  searchPlaceholder,
  checkBoxListLabel,
  initialItems,
  items,
  setItems,
  onPopoverClose,
  anchorEl
}: Props) => {
  const [text, setText] = useState('');

  return (
    <MuiPopover
      open={Boolean(anchorEl)}
      anchorEl={anchorEl}
      onClose={onPopoverClose}
      anchorOrigin={{
        vertical: 'bottom',
        horizontal: 'right'
      }}
      transformOrigin={{
        vertical: 'top',
        horizontal: 'left'
      }}
    >
      <Grid>
        <Paper className={classes.searchText}>
          <InputBase
            placeholder={searchPlaceholder}
            text={text}
            onChange={(e) => {
              setText(e.target.value);
              setItems(filterItems(initialItems, e.target.value));
            }}
          />
        </Paper>
        <Divider/>
        <Paper>
          <CheckBoxList
            label={checkBoxListLabel}
            checkBoxItems={checkBoxItems}
            setCheckBoxItems={setCheckBoxItems}
          />
        </Paper>
      </Grid>
    </MuiPopover>
  );
};

CheckBoxListの実装

CheckBoxList/container.tsx
import Presenter, { CheckBoxItem as Item, CheckedState } from './presenter';

export type CheckBoxItem = Item;
type Props = {
  label: string;
  checkBoxItems: CheckBoxItem[];
  setCheckBoxItems: React.Dispatch<React.SetStateAction<CheckBoxItem[]>>;
};

const judgeCheckBoxListState = (items: CheckBoxItem[]): CheckedState => {
  const allChecked = items.every((item) => item.checked && !item.disabled);
  const allUnchecked = items.every((item) => !item.checked || item.disabled);
  if (allChecked) return 'checked';
  if (allUnchecked) return 'unchecked';
  return 'indeterminate';
};
const judgeCheckBoxListStateFromIndeterminate = (
  items: CheckBoxItem[]
): 'checked' | 'unchecked' => {
  if (items.filter((item) => !item.disabled).every((item) => item.checked))
    return 'unchecked';
  return 'checked';
};

export const CheckBoxList = ({
  label,
  checkBoxItems,
  setCheckBoxItems
}: Props) => {
  const [checkedState, setCheckedState] = useState<CheckedState>(
    judgeCheckBoxListState(checkBoxItems)
  );

  useEffect(() => {
    setCheckedState(judgeCheckBoxListState(checkBoxItems));
  }, [checkBoxItems]);

  const checkAllCheckBoxes = useCallback(
    (checked: boolean) => {
      setCheckBoxItems((currentItems) =>
        currentItems.map((item) => ({ ...item, checked }))
      );
      checkBoxItems
        .filter((item) => !item.disabled && item.checked === !checked)
        .forEach((item) => item.onStateChange(item.checked, item.key));
    },

    [setCheckBoxItems, checkBoxItems]
  );

  const onListValueChange = useCallback(() => {
    if (checkedState === 'checked') {
      checkAllCheckBoxes(false);
      return;
    }
    if (checkedState === 'unchecked') {
      checkAllCheckBoxes(true);
      return;
    }
    if (judgeCheckBoxListStateFromIndeterminate(checkBoxItems) === 'checked') {
      checkAllCheckBoxes(true);
      return;
    }
    if (
      judgeCheckBoxListStateFromIndeterminate(checkBoxItems) === 'unchecked'
    ) {
      checkAllCheckBoxes(false);
      return;
    }
  }, [checkedState, checkBoxItems, checkAllCheckBoxes]);

  const onValueChange = useCallback(
    (index: number, checked: boolean) => {
      setCheckBoxItems((currentItems) => {
        const copiedItems = [...currentItems];
        copiedItems[index].checked = !checked;
        return copiedItems;
      });
    },
    [setCheckBoxItems]
  );

  return (
    <Presenter
      label={label}
      checkedState={checkedState}
      items={checkBoxItems}
      onListValueChange={onListValueChange}
      onValueChange={onValueChange}
      isDisabled={checkBoxItems.length === 0}
    />
  );
};
CheckBoxList/presenter.tsx
import CheckBox from './CheckBox';
import Box from '@material-ui/core/Box';

export type CheckBoxItem = {
  key: string;
  label: string;
  checked: boolean;
  disabled?: boolean;
  onStateChange: (checked: boolean, key: string) => void;
};

export type CheckedState = 'checked' | 'indeterminate' | 'unchecked';

type Props = {
  label: string;
  checkedState: CheckedState;
  items: CheckBoxItem[];
  onListValueChange: () => void;
  onValueChange: (index: number, checked: boolean) => void;
  isDisabled: boolean;
};

const CheckBoxList = ({
  label,
  checkedState,
  items,
  onListValueChange,
  onValueChange,
  isDisabled
}: Props) => {

  return (
    <>
      <CheckBox
        disabled={isDisabled}
        checked={checkedState === 'checked'}
        onValueChange={() => onListValueChange()}
        label={label}
        indeterminate={checkedState === 'indeterminate'}
      />
      <Box>
        {items.map((item, index) => (
          <CheckBox
            key={`${item.key}${index}`}
            checked={item.checked}
            onValueChange={(checked) => {
              onValueChange(index, checked);
              item.onStateChange(checked, item.key);
            }}
            label={item.label}
            disabled={item.disabled}
          />
        ))}
      </Box>
    </>
  );
};

export default CheckBoxList;
CheckBox.tsx
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Checkbox as MuiCheckBox from '@material-ui/core/Checkbox';
import Box from '@material-ui/core/Box';

type Props = {
  label: string;
  checked: boolean;
  onValueChange: (checked: boolean) => void;
  disabled?: boolean;
  indeterminate?: boolean;
};

export const CheckBox: React.FC<Props> = ({
  label,
  checked,
  onValueChange,
  disabled = false,
  indeterminate = false
}) => {
  return (
    <Box>
      <FormControlLabel
        control={
          <MuiCheckBox
            checked={disabled ? false : checked}
            onClick={() => onValueChange(checked)}
            disableRipple
            disabled={disabled}
            indeterminate={disabled ? false : indeterminate}
          />
        }
        label={label}
      />
    </Box>
  );
};

Discussion