🚨

MUI の Stepper で Server Actions を実行する際の注意点

2024/01/08に公開

MUIのStepperを使ってServerActionsを実行すると、正しく値を入力してもバリデーションに引っかかってしまう問題

Next.jsの安定機能になったserver actions と MUIのStepperコンポーネントを使った際にハマったので、現時点で考えられる実装方法を紹介します。

原因

MUIのStepperでは、activeな状態ではないstepのコンポーネントはレンダリングされないため、FormDataの値として判別できないのが原因でした。

原因の確認

MUIの公式で紹介しているサンプルコードで挙動を確認したいと思います。

公式のサンプルコード
'use client'

import * as React from 'react';
import Box from '@mui/material/Box';
import Stepper from '@mui/material/Stepper';
import Step from '@mui/material/Step';
import StepLabel from '@mui/material/StepLabel';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';

const steps = ['Select campaign settings', 'Create an ad group', 'Create an ad'];

export default function Page() {
  const [activeStep, setActiveStep] = React.useState(0);
  const [skipped, setSkipped] = React.useState(new Set<number>());

  const isStepOptional = (step: number) => {
    return step === 1;
  };

  const isStepSkipped = (step: number) => {
    return skipped.has(step);
  };

  const handleNext = () => {
    let newSkipped = skipped;
    if (isStepSkipped(activeStep)) {
      newSkipped = new Set(newSkipped.values());
      newSkipped.delete(activeStep);
    }

    setActiveStep((prevActiveStep) => prevActiveStep + 1);
    setSkipped(newSkipped);
  };

  const handleBack = () => {
    setActiveStep((prevActiveStep) => prevActiveStep - 1);
  };

  const handleSkip = () => {
    if (!isStepOptional(activeStep)) {
      // You probably want to guard against something like this,
      // it should never occur unless someone's actively trying to break something.
      throw new Error("You can't skip a step that isn't optional.");
    }

    setActiveStep((prevActiveStep) => prevActiveStep + 1);
    setSkipped((prevSkipped) => {
      const newSkipped = new Set(prevSkipped.values());
      newSkipped.add(activeStep);
      return newSkipped;
    });
  };

  const handleReset = () => {
    setActiveStep(0);
  };

  return (
    <Box sx={{ width: '100%' }}>
      <Stepper activeStep={activeStep}>
        {steps.map((label, index) => {
          const stepProps: { completed?: boolean } = {};
          const labelProps: {
            optional?: React.ReactNode;
          } = {};
          if (isStepOptional(index)) {
            labelProps.optional = (
              <Typography variant="caption">Optional</Typography>
            );
          }
          if (isStepSkipped(index)) {
            stepProps.completed = false;
          }
          return (
            <Step key={label} {...stepProps}>
              <StepLabel {...labelProps}>{label}</StepLabel>
            </Step>
          );
        })}
      </Stepper>
      {activeStep === steps.length ? (
        <React.Fragment>
          <Typography sx={{ mt: 2, mb: 1 }}>
            All steps completed - you&apos;re finished
          </Typography>
          <Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
            <Box sx={{ flex: '1 1 auto' }} />
            <Button onClick={handleReset}>Reset</Button>
          </Box>
        </React.Fragment>
      ) : (
        <React.Fragment>
          <Typography sx={{ mt: 2, mb: 1 }}>Step {activeStep + 1}</Typography>
          <Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
            <Button
              color="inherit"
              disabled={activeStep === 0}
              onClick={handleBack}
              sx={{ mr: 1 }}
            >
              Back
            </Button>
            <Box sx={{ flex: '1 1 auto' }} />
            {isStepOptional(activeStep) && (
              <Button color="inherit" onClick={handleSkip} sx={{ mr: 1 }}>
                Skip
              </Button>
            )}
            <Button onClick={handleNext}>
              {activeStep === steps.length - 1 ? 'Finish' : 'Next'}
            </Button>
          </Box>
        </React.Fragment>
      )}
    </Box>
  );
}

activeStep=1の場合を検証ツールで確認すると以下のようになっています。
赤枠で囲っている部分がStepperのコンテンツになっており、'Step 1'という文字列を表示するpタグをレンダリングしていることがわかると思います。
activeStep=1の場合

activeStep=2の場合の検証ツールは以下になります。
赤枠で囲っている部分を見ると、先ほど'Step 1'になっていたpタグが無くなり、Step 2'という文字列を表示するpタグがレンダリングされていることが確認できます。
activeStep=2の場合

ServerActionsで利用するFormData型はname='hoge'のように属性を指定すると思います。
MUIのTextFieldコンポーネントでは以下のようになると思います。

<TextField name='title' />

上のStepを切り替えた時の挙動でわかるように、 「name属性で指定したコンポーネントごとDOM操作で削除されてしまうので、 ServerActionsの実行時に非アクティブ状態のコンポーネントの値が取得ず、実行時にバリデーションに引っかかってしまう」 ということになります。

解決策

ボツになった解決策候補

display: 'none' は判別できるので、送信したい値の数だけdisplay: 'none'を指定した送信用のコンポーネントとユーザー入力用のコンポーネントを作成(name属性のみprefixで分ける)する

  • 不要にレンダリングすることになりパフォーマンスが悪い
  • DOM操作で値を操作が可能なのでセキュリティ的に良くない(気がする)

⭕️ 送信時に、実行する関数内でformData.append("attribute", value);のようにして、ServerActionsに渡すFormData型の変数に値を追加する

実装例

'use client';

const Page = () => {
  // NOTE: formData に値を追加
  const addAttributesToFormData = (formData: FormData) => {
    formData.append("attribute1", value1);
    formData.append("attribute2", value2);
  };

  const onSubmit = async (formData: FormData) => {
    // NOTE: formData に値を追加
    addAttributesToFormData(formData);

    // NOTE: server actions を実行
    serverAction(formData);
  };

  return (
    <Stepper
      component="form"
      action={onSubmit}
      // その他の props
    >
      <Step>
        <StepContent>
          ... // その他のコンポーネント
        </StepContent>
      </Step>

      ... // その他の Step

    </Stepper>
  )
};

おわりに

私はこのような方法で実装することにしましたが、Nextの経験は浅いので、他に良い方法などありましたら、ご教授いただけると助かります。

普段は、Rails,React,AWS周りの情報を発信していますので、いいねやフォローをお願いします!
https://zenn.dev/tarou_yuruyuru

Discussion