styled-componentsのThemeProviderをつかってReactのスタイルを管理する

Reactのスタイル管理、どうしていますか。

いろいろな方法があると思いますが、大規模なReactでのフロントエンド開発においてCSSをどう管理するかとても悩みました。私の所属しているチームでは、styled-componentsのThemeProviderを使ってスタイルを管理することにしましたので紹介します。

コード例

例えば簡単なButtonコンポーネントを実装し、propsで背景色を変えたい場面があったとします。

import React from "react";
import styled from "styled-components";

const StyledButton = styled.button`
  background-color: ${props => (props.color === "primary" ? "blue" : "gray")};
`;

export default function Button({ color, children }) {
  // わかりやすいようにpropsを分けて渡しています
  return <StyledButton color={color}>{children}</StyledButton>;
}

この実装だとcolor propsの種類が増えたときに分岐の実装が増えていくため、objectに設定を切り出して書いたりします。

const backgroundColor = {
  default: "gray",
  primary: "blue",
  warning: "orange"
}

const StyledButton = styled.button`
  background-color: ${props => backgroundColor[props.color]};
`;

上の実装でだいぶ変更には強くなります。ただ、もしcolorのprimaryやwarning設定をある一部分だけ違う色に変えたいという場面があったとします。Buttonコンポーネント内で色を固定してしまっているため、propsを追加するか、styled-componentsのcss propsを渡すか、もう一つButtonをラップしたコンポーネントを作るなどの方法を取らねばなりません。

そういった場合にThemeProviderを使って実装すると便利なことがあります。

// ButtonGroup.js(Button.jsを使う側)

import React from "react";
import { ThemeProvider } from "styled-components";
import Button from "./Button";

const theme = {
  button: {
    backgroundColor: {
      default: "gray",
      primary: "blue",
      warning: "orange"
    }
  }
};

export default function ButtonGroup() {
  return (
    <ThemeProvider theme={theme}>
      <Button color="default">Default</Button>
      <Button color="primary">Primary</Button>
      <Button color="warning">Warning</Button>
    </ThemeProvider>
  );
}
// Button.js

import React from "react";
import styled from "styled-components";

// ThemeProvider内のコンポーネントはpropsにthemeがセットされている
const StyledButton = styled.button`
  background-color: ${props => props.theme.button.backgroundColor[props.color]};
`;

export default function Button(props) {
  return <StyledButton {...props} />;
}

Buttonコンポーネントを使う側(ButtonGroup.js)でThemeProviderを使い theme propsで色を設定しているobjectを渡します。ThemeProviderで囲まれた全コンポーネントには theme というpropsが自動で追加されており、 props.theme にはThemeProviderで渡したobjectが入っています。

これでコンポーネントを利用する側からスタイルをカスタムしやすくなりました。同時にButtonコンポーネントではCSSとしてThemeProviderからの値をセットするだけで、背景色自体の管理はしなくなっています。

以下のようにobjectを書き換えれば、一部分だけ設定を変更することができます。

// ButtonGroup.js

import React from "react";
import { ThemeProvider } from "styled-components";
import deepmerge from "deepmerge"
import Button from "./Button";

const defaultTheme = {
  button: {
    backgroundColor: {
      default: "gray",
      primary: "blue",
      warning: "orange"
    }
  }
};
const customTheme = deepmerge(defaultTheme, {
  button: {
    color: {
      primary: "green",
      warning: "red"
    }
  }
});

export default function ButtonGroup() {
  return (
    <ThemeProvider theme={defaultTheme}>
      <div>
        <Button color="default">Default</Button>
        <Button color="primary">Primary</Button>
        <Button color="warning">Warning</Button>
      </div>
      <div>
        <ThemeProvider theme={customTheme}>
          <Button color="default">Default</Button>
          <Button color="primary">Primary</Button>
          <Button color="warning">Warning</Button>
        </ThemeProvider>
      </div>
    </ThemeProvider>
  );
}

ThemeProvider自体はReactのContextの仕組みを使っているので、一番近いところで渡されているThemeProviderの値がセットされるようになっています。カスタムしたい場合は、デフォルトのテーマ設定objectからマージして書き換えたい部分のみ上書きするようにします。

これでcustomThemeを渡した方のみカスタムした背景色が当たるようになりました。

試してみたソースコードはこちらに置いています。

ThemeProviderを利用するメリット

コンポーネントとスタイル管理を切り離して管理できる

上記の例ではButtonコンポーネント内で背景色のスタイル管理をしなくなりました。つまりスタイル変更のためだけにButtonコンポーネントを修正する必要がなくなるということです。背景色だけでなくサイズやテキストの色などもThemeProviderで管理するようにすることですべてコンポーネントを利用する側でスタイル管理ができるようになります。

ただStorybookでコンポーネントカタログを作るときなど、ThemeProviderを利用せずコンポーネントのみで表示させたい場合、 props.theme がセットされないためundefinedになりJavaScriptエラーが発生してしまいます。その場合ReactのdefaultPropsを利用するとよいと思います。

// Button.js

import React from "react";
import styled from "styled-components";
import { defaultTheme } from "./defaultTheme"

const StyledButton = styled.button`
  background-color: ${props => props.theme.button.backgroundColor[props.color]};
`;
StyledButton.defaultProps = {theme: defaultTheme}
 
export default function Button({ color, children }) {
  return <StyledButton color={color}>{children}</StyledButton>;
}

これでThemeProviderを利用しない場合でもdefaultPropsによりデフォルトのテーマ設定で props.theme が渡された状態になります。

CSSの詳細度の戦いになりにくい

コンポーネント内でCSSを設定していた場合、そのCSSを上書きするためには利用するコンポーネント側でCSSの詳細度を考えながら書かなければなりません。ときには !important を使わないといけない悲しい場面もあるかもしれません。

ThemeProviderを利用した場合、セットするthemeはJSのobjectなのでobject自体を上書きして渡してしまえば詳細度の戦いになりません。styled-componentsの仕様として、CSSの値にundefinedを渡せばプロパティ自体出力されなくなるので、無駄なCSSが出力されないというメリットもあります。

TypeScriptでも書ける

https://github.com/styled-components/styled-components/issues/1589#issuecomment-456641381 を参考にtheme props型をつけることができます。@types/styled-components の実装でDefaultThemeのinterfaceを拡張するとthemeに型をつけることができるようになっています。テーマ管理しているobjectのtypeofをとり以下のようにDefaultTheme interfaceに適用します。

import { defaultTheme } from "path/to/defaultTheme";

type ITheme = typeof defaultTheme;

declare module "styled-components" {
  interface DefaultTheme extends ITheme {}
}

少しロジックが入ったりする場合は、styled-componentsのテンプレート文字列全体を関数にして最後にcssを使って文字列を返すような実装もできます。

import React from "react";
import styled, { css } from "styled-components";
import { defaultTheme } from "./defaultTheme"

type Props = {
  color?: "default" | "primary" | "warning";
  disabled?: boolean;
  children: React.ReactNode;
}

const StyledButton = styled.button<Props>`
  ${({theme, color, disabled}) => {
    let backgroundColor = disabled ? "darkgray" : theme.button.backgroundColor[color];
    return css`
      background-color: ${backgroundColor};
    `
  }}
`;
StyledButton.defaultProps = {theme: defaultTheme}


export default function Button(props: Props): React.ReactElement {
  return <StyledButton{...props} />;
}

最後に

デフォルトのテーマ設定objectを別のobjectに切り替えるだけで、ユーザーごとのテーマ切り替えやダークテーマの対応がしやすかったりするメリットもあると思います。

直感的なCSSファイルでの実装ではないため、デザイナーがコードまで修正しているチームの場合修正しにくいデメリットはあると思いますが、エンジニアが管理しているチームでは管理しやすくなると思いますのでぜひ試してみてください。

参照リンク