React で Snackbar を作る
Snackbar とは
こういうやつ.
アイテムを削除したときなどに一時的に表示され, 一定時間が経過すると自動で消えるUIコンポーネントです. ライブラリで提供されていることが多いですが, 今回は React のみで実装します.
環境
- React 18
- tailwindcss 3.4.1
- Storybook 8.0
fowardRef で外部から参照する
Snackbar をポップアウトさせるため, コンポーネントを外部から操作できるようにします. これには forwardRef
と useImperativeHandle
を使います[1][2]. forwardRef
によってコンポーネントを外部から参照できるようにし, useImperativeHandle
によってコンポーネントに独自の公開ハンドラを追加することができます. onClick
などの一般的な公開ハンドラとは異なり, 外部から対象のコンポーネントの状態を操作することができるようになります.
まず, forwardRef
だけを使って親コンポーネントに ref
を渡します.
export const useSnackbar = () => {
const snackbarRef = useRef<() => void>(null);
return {
snackbarRef,
};
};
export const Snackbar = forwardRef<() => void, {}>((_, ref) => {
return <div className="bg-black p-2"></div>;
})
Snackbar.displayName = "Snackbar";
ref
を返す関数 useSnackbar
は useRef
のラッパーで, この関数を使って ref
を渡すようにします. forwardRef
は引数として関数コンポーネントではなく, render
関数をとります. 関数コンポーネントとほとんど変わりませんが, 第二引数として ref
を持ちます. これによって, 親コンポーネントから ref
を指定することができます.
const Parent = () => {
const { snackbarRef } = useSnackbar();
return <Snackbar ref={snackbarRef}>;
}
これで外部からコンポーネントを参照して操作することができます.
useImperativeHandle で外部から操作する
次に useImperativeHandle
を使って独自のハンドラを定義し, コンポーネントの状態を外部から操作できるようにします. useImperativeHandle
の第二引数に関数 createHandle
を渡します. createHandle
は Object
を返す関数で, この Object
にハンドラを登録します.
const createHandle = () => {
return {
show: () => {}
}
}
useImperativeHandle
を使って登録されたハンドラは Object
のキーを参照して呼び出すことができます.
ref.current.show();
これを使って snackbar を実装していきます. snackbar にハンドラ show
を用意します. show
を呼び出すと, snackbar の内部状態 isDisplayed
をトグルして snackbar の表示・非表示を切り替えます.
interface SnackbarHandles {
show: () => void;
}
export const useSnackbar = () => {
const snackbarRef = useRef<SnackbarHandles>(null);
const showSnackbar = useCallback(() => {
snackbarRef.current && snackbarRef.current.show();
}, []);
return {
snackbarRef,
showSnackbar
};
};
export const Snackbar = forwardRef<SnackbarHandles, {}>((_, ref) => {
const [isDisplayed, setIsDisplayed] = useState(false);
useImperativeHandle(
ref,
() => ({
show: () => {
setIsDisplayed(!isDisplayed);
},
}), [isDisplayed]
);
return <div className={`${isDisplayed ? "block" : "hidden"} ` + "bg-black p-2"}></div>;
})
Snackbar.displayName = "Snackbar";
Storybook での表示
ここで Storybook を用いて表示の確認をします. Storybook では Meta
に登録したコンポーネントがそのまま Story
内でレンダリングされます. しかし forwardRef
を使う場合, 外部から ref
を登録しなければなりません. そのため decorators
プロパティを使って表示を制御します. 今回は button
を使って snackbar を表示させてみましょう.
export const Default: Story = {
args: {},
decorators: [
() => {
const { snackbarRef, showSnackbar } = useSnackbar();
return (
<>
<button className="border p-2 w-max" onClick={() => showSnackbar()}>
show
</button>
<Snackbar ref={snackbarRef} />
</>
);
},
],
};
これで実際にボタンを押して表示・非表示を確認できました.
メッセージを設定する
Snackbar に表示するメッセージを設定できるようにします. コンポーネントの引数として設定することもできますが, 例えば成功・失敗時など表示するタイミングでメッセージを変更できるように show
ハンドラの引数として設定してみましょう. これには useImperativeHandle
に登録するハンドラに引数を設定して, バケツリレーするだけでよいです.
interface SnackbarHandles {
show: (message: string) => void;
}
export const useSnackbar = () => {
const snackbarRef = useRef<SnackbarHandles>(null);
const showSnackbar = useCallback((message: string) => {
snackbarRef.current && snackbarRef.current.show(message);
}, []);
return {
snackbarRef,
showSnackbar,
};
};
export const Snackbar = forwardRef<SnackbarHandles, {}>((_, ref) => {
const [isDisplayed, setIsDisplayed] = useState(false);
const [message, setMessage] = useState("");
useImperativeHandle(
ref,
() => ({
show: (message) => {
setMessage(message);
setIsDisplayed(!isDisplayed);
},
}), [isDisplayed]
);
return (
<div
className={
`${isDisplayed ? "block" : "hidden"} ` +
"bg-black"
}
>
<p className="p-2 text-white">{message}</p>
</div>
);
外部から呼び出すときも通常通り引数をつけてやるだけです.
export const Default: Story = {
args: {},
decorators: [
() => {
const { snackbarRef, showSnackbar } = useSnackbar();
return (
<>
<button className="border p-2 w-max" onClick={() => showSnackbar("Hello, world!")}>
show
</button>
<Snackbar ref={snackbarRef} />
</>
);
},
],
};
Snackbar を自動で消す
Snackbar の動作としては一定時間経過すると自動で消えるのが一般的です. そこで表示後一定時間が経過したら消えるように変更します. これは単純に useEffect
で表示状態を検知して, setTimeout
を使って一定時間後に非表示に更新すればよいだけです.
export const Snackbar = forwardRef<SnackbarHandles, {}>((_, ref) => {
const [isDisplayed, setIsDisplayed] = useState(false);
useImperativeHandle(
ref,
() => ({
show: (message) => {
setMessage(message);
setIsDisplayed(true);
},
}), []
);
useEffect(() => {
if (isDisplayed) {
const timeout = setTimeout(() => setIsDisplayed(false), 1000);
return () => clearTimeout(timeout);
}
}, [isDisplayed]);
return (
<div
className={
`${isDisplayed ? "block" : "hidden"} ` +
"bg-black"
}
>
<p className="p-2 text-white">{message}</p>
</div>
);
表示する時間はてきとうな定数としていますが, これも message
と同様に show
ハンドラの引数としたり, コンポーネントの引数として渡すこともできます.
opacity のアニメーション
さて最後に, アニメーションをつけてリッチな見た目にしてみましょう. Snackbar の表示が消えるときにフェードアウトさせます. CSSの opacity
と translation
を使います. フェードアウトは opacity
を 0にするときだけ, translation
を設定することで実現することができます.
return (
<div
className={
`${isPopping ? "opacity-100" : "opacity-0 transition-opacity delay-150 duration-300 ease-in-out"} ` + // アニメーションを制御する変数を isPopping とする
"bg-black"
}
>
<p className="p-2 text-white">{message}</p>
</div>
);
ここで表示に関する状態管理とアニメーションの状態管理について考えます. この snackbar は
- なんらかのトリガによって表示状態になる.
- 一定時間後, フェードアウトのアニメーションが開始される.
- フェードアウトのアニメーションが完了後, 非表示状態になる.
という動作が期待されます. これらの状態は isDisplayed
と isPopping
との 2つの状態変数によって管理されるため, このように言い換えられます.
- なんらかのトリガによって
isDisplayed
とisPopping
がtrue
になる. - 一定時間後,
isPopping
がfalse
になる(isDisplayed
はtrue
のまま). - フェードアウトのアニメーションが完了後,
idDisplayed
がfalse
になる.
これはコードでは以下のように書くことができます. 1つ目の useEffect
では snackbar の見た目上の表示を制御しています. 一定時間経過後にフェードアウトのアニメーションが開始されます. 2つ目の useEffect
ではCSSの diplay
プロパティを制御しています. フェードアウトのアニメーションが開始された時点, つまり isPopping
が false
になると else
節が実行され, 一定時間経過後に isDisplayed
が false
になります.
useEffect(() => {
if (isPopping) {
const timeout = setTimeout(() => setIsPopping(false), 1000);
return () => clearTimeout(timeout);
}
}, [isPopping]);
useEffect(() => {
if (isPopping) {
setIsDisplayed(true);
} else {
const timeout = () => setTimeout(() => setIsDisplayed(false), 500);
return () => clearTimeout(timeout());
}
}, [isPopping]);
return (
<div
className={
`${isDisplayed ? "block" : "hidden"} ` +
`${isPopping ? "opacity-100" : "opacity-0 transition-opacity delay-150 duration-300 ease-in-out"} ` +
"bg-black"
}
>
<p className="p-2 text-white">{message}</p>
</div>
);
これでフェードアウトを表現することができました.
この snackbar の例から, さらに undo
や close
ボタンを追加したり, さらには opacity
ではなく translate
を使うことで toast バナーを実装することもできます. ライブラリを使わずに実装することで, 多彩な表現を実現することができます.
Discussion