型(集合)という観点でリファクタを考える
React*TSでコードをきれいに書こう!とするときに考えていることって
- 単一責任の原則を守りつつ
- 疎結合に組んでいく
くらいしか自分はなくて、その補助線として型(集合)があるなー、という感覚がある。
今回は補助線の話をもうちょい自分の中で整理したいなと思い、ひとつ例をとってリファクタを何パターンか試してみる。
題材
shadcn/uiのCommand
export const CommandMenu = () => {
const router = useRouter();
const [open, setOpen] = useState(false);
const { setTheme } = useTheme();
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const runCommand = useCallback((command: () => unknown) => {
setOpen(false);
command();
}, []);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Link">
<CommandItem
onSelect={() => runCommand(() => router.push("/mypage"))}
>
<Link className="mr-2 h-4 w-4" />
My Page
</CommandItem>
<CommandItem onSelect={() => runCommand(() => router.push("/hoge"))}>
<Link className="mr-2 h-4 w-4" />
Hoge
</CommandItem>
<CommandItem onSelect={() => runCommand(() => router.push("/fuga"))}>
<Link className="mr-2 h-4 w-4" />
Fuga
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Theme">
<CommandItem onSelect={() => runCommand(() => setTheme("light"))}>
<SunMedium className="mr-2 h-4 w-4" />
Light
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme("dark"))}>
<Moon className="mr-2 h-4 w-4" />
Dark
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
};
Step 1: Linkでまとめる
まず明らかに目につくのはViewのこの部分。繰り返しが多いのでまとめたくなる。
<CommandGroup heading="Link">
<CommandItem
onSelect={() => runCommand(() => router.push("/mypage"))}
>
<Link className="mr-2 h-4 w-4" />
My Page
</CommandItem>
<CommandItem onSelect={() => runCommand(() => router.push("/hoge"))}>
<Link className="mr-2 h-4 w-4" />
Hoge
</CommandItem>
<CommandItem onSelect={() => runCommand(() => router.push("/fuga"))}>
<Link className="mr-2 h-4 w-4" />
Fuga
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Theme">
<CommandItem onSelect={() => runCommand(() => setTheme("light"))}>
<SunMedium className="mr-2 h-4 w-4" />
Light
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme("dark"))}>
<Moon className="mr-2 h-4 w-4" />
Dark
</CommandItem>
</CommandGroup>
LinkとThemeでCommandGroup
がふたつあることに気がつく。
Themeはこれ以上増えなさそうだけれど、Linkはどんどん増えていきそうなので、まずはLinkだけでまとめてみる。
イメージこういう感じになるはず。
const links = [
{ href: "/mypage", label: "My Page" },
{ href: "/hoge", label: "Hoge" },
{ href: "/fuga", label: "Fuga" },
];
...
<CommandGroup heading="Link">
{links.map((link) => (
<CommandItem
key={link.label}
onSelect={() => runCommand(() => router.push(link.href))}
>
<Link className="mr-2 h-4 w-4" />
{link.label}
</CommandItem>
))}
</CommandGroup>
これは、こういうLinkという型(集合)を見出してリファクタした、という感覚
type Link = {
href: Route;
label: string;
};
Step 2: Commandでまとめる
Linkのほうがまとまったとはいえ、このままでいいのかと少し悩む。
- Themeの方も何か変更があるかもしれない
- LinkでもThemeでもないコマンドが追加されるかもしれない
なので、LinkとかThemeとかまだ見ぬXXXとかに関わらない、より大きいまとまりでリファクタしてみる。
一般化したいのはこのCommandItemのところ。
<CommandItem onSelect={() => runCommand(() => setTheme("dark"))}>
<Moon className="mr-2 h-4 w-4" />
Dark
</CommandItem>
これを見ると、例えばこんな感じの型が見えてくる。
type Command = {
label: string;
Icon: LucideIcon; // たまたまLucide iconsをつかっているのでこの型だけれども、準ずるものならなんでも
onSelect: () => void;
};
Commandを軸にリファクタすると、こんな感じになる。LinkもThemeも同じ感じに書けるし、それ以外のコマンドが来ても同じように書ける。
const linkCommands = [
{
label: "My Page",
Icon: Link,
onSelect: () => runCommand(() => router.push("/mypage")),
},
{
label: "Hoge",
Icon: Link,
onSelect: () => runCommand(() => router.push("/hoge")),
},
{
label: "Fuga",
Icon: Link,
onSelect: () => runCommand(() => router.push("/fuga")),
},
];
const themeCommands = [
{
label: "Light",
Icon: SunMedium,
onSelect: () => runCommand(() => setTheme("light")),
},
{
label: "Moon",
Icon: SunMedium,
onSelect: () => runCommand(() => setTheme("dark")),
},
];
...
<CommandGroup heading="Link">
{linkCommands.map((command) => (
<CommandItem key={command.label} onSelect={command.onSelect}>
<command.Icon className="mr-2 h-4 w-4" />
{command.label}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Theme">
{themeCommands.map((command) => (
<CommandItem key={command.label} onSelect={command.onSelect}>
<c.Icon className="mr-2 h-4 w-4" />
{command.label}
</CommandItem>
))}
</CommandGroup>
Step3: CommandGroupでまとめる
↑を見ると、LinkとThemeをさらに抽象化した集合CommandGroupが見えてくる。
export type CommandGroup = {
heading: string;
commands: Command[];
};
先程のlinkCommands, themeCommandsを再利用してこんな感じになる。
この先いくらCommandが追加されようとも、commandGroups
を変更するだけでよく、Viewを触る必要がない。十分に抽象化されたと言える。
const commandGroups = [
{
heading: "Link",
commands: linkCommands,
},
{
heading: "Theme",
commands: themeCommands,
},
];
...
{commandGroups.map(({ heading, commands }) => (
<>
<CommandGroup heading={heading} key={heading}>
{commands.map((command) => (
<CommandItem key={command.label} onSelect={command.onSelect}>
<command.Icon className="mr-2 h-4 w-4" />
{command.label}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
))}
Stap1~3までが、「リファクタの補助線として型(集合)がある」というイメージ。
振り返ってみると、ここで言っているリファクタとは「抽象化」にほかならず、どの抽象を選んで整理するかという話でしかない。
1<2<3の順で抽象度が上がっている。
viewとdataの分離という観点で見ると、どれだけdataが増えてもviewに触る必要がない3が抽象化として優れていそうだが、リファクタとして別に1が特別劣っているとは思わない。1は1で簡潔さがあるし、今後linkコマンドしか増える予定がないならあれで十分だ。
抽象度の定規とリファクタとしての価値の定規はまた別、という気持ち。
抽象度と言っているけれど、用語的には凝集度のほうが即しているのかな。
それでいうと1,2は論理的凝集で、3までいくと機能的凝集という感じはする。
ここらへんの用語の当て方はよくわからない...。
ここまででViewをいじってきてこうなったわけだけれども、今度はロジックの方をリファクタしていきたい。
export const CommandMenu = () => {
const router = useRouter();
const [open, setOpen] = useState(false);
const { setTheme } = useTheme();
const linkCommands = [
{
label: "My Page",
Icon: Link,
onSelect: () => runCommand(() => router.push("/mypage")),
},
{
label: "Hoge",
Icon: Link,
onSelect: () => runCommand(() => router.push("/hoge")),
},
{
label: "Fuga",
Icon: Link,
onSelect: () => runCommand(() => router.push("/fuga")),
},
];
const themeCommands = [
{
label: "Light",
Icon: SunMedium,
onSelect: () => runCommand(() => setTheme("light")),
},
{
label: "Moon",
Icon: SunMedium,
onSelect: () => runCommand(() => setTheme("dark")),
},
];
const commandGroups = [
{
heading: "Link",
commands: linkCommands,
},
{
heading: "Theme",
commands: themeCommands,
},
];
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const runCommand = useCallback((command: () => unknown) => {
setOpen(false);
command();
}, []);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{commandGroups.map(({ heading, commands }) => (
<>
<CommandGroup heading={heading} key={heading}>
{commands.map((command) => (
<CommandItem key={command.label} onSelect={command.onSelect}>
<command.Icon className="mr-2 h-4 w-4" />
{command.label}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
))}
</CommandList>
</CommandDialog>
);
};
まず目につくのはここらへん。
const linkCommands = [
{
label: "My Page",
Icon: Link,
onSelect: () => runCommand(() => router.push("/mypage")),
},
{
label: "Hoge",
Icon: Link,
onSelect: () => runCommand(() => router.push("/hoge")),
},
{
label: "Fuga",
Icon: Link,
onSelect: () => runCommand(() => router.push("/fuga")),
},
];
const themeCommands = [
{
label: "Light",
Icon: SunMedium,
onSelect: () => runCommand(() => setTheme("light")),
},
{
label: "Moon",
Icon: SunMedium,
onSelect: () => runCommand(() => setTheme("dark")),
},
];
-
runCommand
は全Command共通なので個別のCommandからは抽出しておきたい - linkCommands内、themeCommands内でそれぞれもう少し抽象化できそう
というのがパッと思いつく。
runCommand
は全Command共通なので個別のCommandからは抽出しておきたい
全Command共通ということは、最終的にcommandGroups
になるときにrunCommand
を合成すれば良いということになる。
なのでこういう関数を書いてみる。
import type { Command, CommandGroup } from "./type";
const setCommandRunner = (
commands: Command[],
runCommand: (_command: () => void) => void
): Command[] =>
commands.map((command) => ({
...command,
onSelect: () => runCommand(command.onSelect),
}));
// integrate runCommand into each command
export const generateCommandGroups = (
commangGroups: CommandGroup[],
runCommand: (_command: () => void) => void
) =>
commangGroups.map(({ heading, commands }) => ({
heading,
commands: setCommandRunner(commands, runCommand),
}));
これを使うとこういう事になり、個別のCommandからrunCommandを抜くことができた。
const linkCommands = [
{
label: "My Page",
Icon: Link,
onSelect: () => router.push("/mypage"),
},
{
label: "Hoge",
Icon: Link,
onSelect: () => router.push("/hoge"),
},
{
label: "Fuga",
Icon: Link,
onSelect: () => router.push("/fuga"),
},
];
const themeCommands = [
{
label: "Light",
Icon: SunMedium,
onSelect: () => setTheme("light"),
},
{
label: "Dark",
Icon: Moon,
onSelect: () => setTheme("dark"),
},
];
const commandGroups = generateCommandGroups(
[
{
heading: "Link",
commands: linkCommands,
},
{
heading: "Theme",
commands: themeCommands,
},
],
runCommand
);
linkCommands
内、themeCommands
内でそれぞれもう少し抽象化できそう
-
linkCommands
: Iconとrouter.pushが同一 -
themeCommands
: labelとthemeの文字列が(ケースを合わせれば)同一、setThemeが同一
const linkCommands = [
{
label: "My Page",
Icon: Link,
onSelect: () => router.push("/mypage"),
},
{
label: "Hoge",
Icon: Link,
onSelect: () => router.push("/hoge"),
},
{
label: "Fuga",
Icon: Link,
onSelect: () => router.push("/fuga"),
},
];
const themeCommands = [
{
label: "Light",
Icon: SunMedium,
onSelect: () => setTheme("light"),
},
{
label: "Dark",
Icon: Moon,
onSelect: () => setTheme("dark"),
},
];
それぞれカスタムフックに押し込んでいく。
useLinkCommands
こんな感じ。linkを追加したければlinks
をいじれば良い。
const useLinkCommands = () => {
const router = useRouter();
const links = [
{ href: "/mypage", label: "My Page" },
{ href: "/hoge", label: "Hoge" },
{ href: "/fuga", label: "Fuga" },
];
const linkCommands = links.map(({ href, label }) => ({
label,
Icon: Link,
onSelect: () => router.push(href),
}));
return linkCommands;
};
useThemeCommands
こんな感じ。labelとthemeの文字列が同一なのがたまたまだろと思う場合はlabelをべたがきしても良いと思う。
const useThemeCommands = () => {
const { setTheme } = useTheme();
const toTitleCase = (word: string) =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
const themes = [
{
theme: "light",
Icon: SunMedium,
},
{
theme: "dark",
Icon: Moon,
},
];
const themeCommands = themes.map(({ theme, Icon }) => ({
label: toTitleCase(theme),
Icon,
onSelect: () => setTheme(theme),
}));
return themeCommands;
};
ここまでやると、こんな感じになる。
export const CommandMenu = () => {
const [open, setOpen] = useState(false);
const linkCommands = useLinkCommands();
const themeCommands = useThemeCommands();
const runCommand = useCallback((command: () => unknown) => {
setOpen(false);
command();
}, []);
const commandGroups = generateCommandGroups(
[
{
heading: "Link",
commands: linkCommands,
},
{
heading: "Theme",
commands: themeCommands,
},
],
runCommand
);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{commandGroups.map(({ heading, commands }) => (
<>
<CommandGroup heading={heading} key={heading}>
{commands.map((command) => (
<CommandItem key={command.label} onSelect={command.onSelect}>
<c.Icon className="mr-2 h-4 w-4" />
{command.label}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
))}
</CommandList>
</CommandDialog>
);
};
Commandの生成がHooksに隠蔽されていい感じになったとは思いつつ、そもそも最終的に関心がある切り口はCommandGroupであるので、最初からCommandGroupを返せば良いことに気がつく。
というわけでそれぞれこんな感じになおして
const useLinkCommandGroup = () => {
...
return {
heading: "Link",
commands: linkCommands,
};
};
const useThemeCommandGroup = () => {
...
return {
heading: "Theme",
commands: themeCommands,
};
};
こうしてあげる。ロジックの方もCommandGroupを軸として整理されて、Viewの抽象と噛み合っている。
const linkCommandGroup = useLinkCommandGroup();
const themeCommandGroup = useThemeCommandGroup();
const commandGroups = generateCommandGroups(
[linkCommandGroup, themeCommandGroup],
runCommand
);
現状はこんなかんじ。
export const CommandMenu = () => {
const [open, setOpen] = useState(false);
const runCommand = useCallback((command: () => unknown) => {
setOpen(false);
command();
}, []);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const linkCommandGroup = useLinkCommandGroup();
const themeCommandGroup = useThemeCommandGroup();
const commandGroups = generateCommandGroups(
[linkCommandGroup, themeCommandGroup],
runCommand
);
return (
...
ここまで来ると、ロジック層の中でこいつらだけが浮いて見えるようになる。
const [open, setOpen] = useState(false);
const runCommand = useCallback((command: () => unknown) => {
setOpen(false);
command();
}, []);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
少し強引ではあるけれど、「Commandの開閉に関心がある」としてひとまとめにしてしまってもよいだろう。
例えばこんな感じ。
export const useToggleCommandWithKey = (activateKey = "k") => {
const [open, setOpen] = useState(false);
// Close Command when running a command
const runCommand = useCallback((command: () => void) => {
setOpen(false);
command();
}, []);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === activateKey && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, [activateKey]);
return { open, setOpen, runCommand };
};
こうなる。だいぶスッキリした。
export const CommandMenu = () => {
const { open, setOpen, runCommand } = useToggleCommandWithKey();
const linkCommandGroup = useLinkCommandGroup();
const themeCommandGroup = useThemeCommandGroup();
const commandGroups = generateCommandGroups(
[linkCommandGroup, themeCommandGroup],
runCommand
);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{commandGroups.map(({ heading, commands }) => (
<>
<CommandGroup heading={heading} key={heading}>
{commands.map((command) => (
<CommandItem key={command.label} onSelect={command.onSelect}>
<command.Icon className="mr-2 h-4 w-4" />
{command.label}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
))}
</CommandList>
</CommandDialog>
);
};
改めて、これが「リファクタの補助線として型(集合)がある」というイメージ。
- CommandGroupを軸としてViewを整理し
- ロジックの方もCommandGroupを軸とすることで、「CommandGroup1, CommandGroup2, CommandGroup以外」とまとまった
という感じ。
まだまだやれることはありそうで、パッと思いつくのはここらへん。
- emptyMessageやplaeholderなども抽象化し、それらやCommandGroupを受け取るPresenterと、CommandGroupを生成するなどのロジックを記述するContainerに分離する
- ロジックのところどころに型注釈をつけて関心を明確にする (
const useThemeCommandGroup = (): CommandGroup
みたいなこと)
1は今回の本題でないけれど、2は本題と関係がある。
「どの抽象を選んで書いたコードですよ」というのが明示的に伝わると良いよね、という話で、それを明確にするために型「注釈」をつける。
今回のCommandMenuは選べる抽象が何段階かあって、題材として面白いと思う。
抽象化の落とし所をどこにするのかはいろんな事情が絡んできそうで、要研究。