はじめに

  • 「TypeScript と React/Next.js でつくる 実践 Web アプリケーション開発」の本を読んだ

コンポーネント思想

presentational/container

presentational はデザインの部分を担当する。一方で container がビジネスロジックを担当する。見た目と挙動で責務を分ける形で、可読性と保守性を分けている。

Atomic Design

デザインを階層的に定義することで一貫性を保ち管理しやすくしている。

styled-component

書き方

styled.要素名スタイル``の書き方でやる。

import { NextPage } from "next";
import styled from "styled-components";

type ButtonProps = {
  color: string,
  backgroundColor: string
};
//propsも定義することができる
const Button =
  styled.button <
  ButtonProps >
  `
  color: ${props => props.color};
  background: ${props => props.backgroundColor};
  border: 2px solid ${props => props.color};

  font-size: 2em;
  margin: 1em;
  padding: 0.25em 1em;
  border-radius: 8px;
  cursor: pointer;
`;

const Page: NextPage = () => {
  return (
    <div>
      <Button backgroundColor="transparent" color="#FF0000">
        Hello
      </Button>

      <Button backgroundColor="1E90FF" color="white">
        Hello
      </Button>
    </div>
  );
};

export default Page;

mixin

共通のものは mixin として定義し、css 内部に入れることで共通のものを適用できる。

const fontSize = css`
  font-size: 1em;
`;

const Button =
  styled.button <
  ButtonProps >
  `
  color: ${props => props.color};
  background: ${props => props.backgroundColor};
  border: 2px solid ${props => props.color};

  ${fontSize}
  margin: 1em;
  padding: 0.25em 1em;
  border-radius: 8px;
  cursor: pointer;
`;

スタイルを継承する

スタイルを継承するときに有用。

const Text = styled.p`
 ${fontSize};
 font-weight:bold
`
//styled(Text)でTextのstyleを継承する
const TextBorder = styled(Text)`
padding:1px
`

//pタグのなかにHelloが入り、TextBorderとTextのstyleが適用される。
<TextBorder>
Hello
</TextBorder>


//asを利用することでaタグとして機能することができるようになる。
<TextBorder as='a' href="/">
Hello
</TextBorder>

theme

theme を設定しておきそれを props で受け取ることで theme の内容を参照できるようになる。

//theme.ts
export const theme = {
  space: ["0px", "4px", "8px", "16px", "24px", "32px"],
  colors: {
    white: "#ffffff",
    black: "#000000",
    red: "#ff0000"
  },
  fontSizes: ["12px"]
};

//pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "styled-components";
import { theme } from "./theme";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider theme={theme}>
      <Component {...pageProps} />
    </ThemeProvider>
  );
}

export default MyApp;

//pages/sampleTheme.tsx
import { NextPage } from "next";
import styled from "styled-components";

const Text = styled.span`
  color: ${props => props.theme.colors.red};
`;

const Page: NextPage = () => {
  return (
    <div>
      <Text>Themeから参照したもの</Text>
    </div>
  );
};

export default Page;

Storybook

個々のコンポーネントを確認することができるツール

npx sb@latest init
npm run storybook

簡単なボタン

//components/StyledButton/index.tsx
import styled, { css } from "styled-components";

const variants = {
  primary: {
    color: "#ffffff",
    backgroundColor: "#1D3461",
    border: "none",
  },
  success: {
    color: "#ffffff",
    backgroundColor: "green",
    border: "none",
  },
  transparent: {
    color: "#111111",
    backgroundColor: "transparent",
    border: "1px solid black",
  },
} as const;

type StyledButtonProps = {
  //keyof typeof→ typeofが変数を型に変換、keyでconst化
  variant: keyof typeof variants;
};
//{variant} → props.variantで参照可能なため{}で展開したものを入れている
export const StyledButton = styled.button<StyledButtonProps>`
  ${({ variant }) => {
    const style = variants[variant];
    return css`
      color: ${style.color};
      backgroundcolor: ${style.backgroundColor};
      border: ${style.border};
    `;
  }}

  border-radius:12px;
  font-size: 14px;
  height: 38px;
  line-height: 22px;
  letter-spacing: 0;
  cursor: pointer;

  &:focus {
    outline: none;
  }
`;

//stories/StyledButton.stories.tsx
import { ComponentMeta } from "@storybook/react";
import { StyledButton } from "../components/StyledButton";

export default {
  title: "StyledButton",
  component: StyledButton,
  //クリックの宣言をすることができるようになる
  argTypes: { onClick: { action: "clicked" } },
} as ComponentMeta<typeof StyledButton>;

export const Primary = (props: any) => {
  return (
    <StyledButton {...props} variant="primary">
      Primary
    </StyledButton>
  );
};

export const Success = (props: any) => {
  return (
    <StyledButton {...props} variant="success">
      Success
    </StyledButton>
  );
};

export const Transparent = (props: any) => {
  return (
    <StyledButton {...props} variant="transparent">
      Transparent
    </StyledButton>
  );
};

action

//actionで任意のclickイベントが動くかを確認できる
const incrementAction = action("increment");

export const Primary = (props: any) => {
  const [count, setCount] = useState(0);
  const onClick = (e: React.MouseEvent) => {
    incrementAction(e, count);
    setCount(c => c + 1);
  };

  return (
    <StyledButton {...props} variant="primary" onClick={onClick}>
      count:{count}
    </StyledButton>
  );
};

control 制御

定義したコンポーネントに渡す props を設定することができる

import { ComponentMeta, ComponentStory } from "@storybook/react";
import { StyledButton } from "../components/StyledButton";
import { action } from "@storybook/addon-actions";
import React, { useState } from "react";
export default {
  title: "StyledButton",
  component: StyledButton,
  argTypes: {
    //propsに渡すものを定義する
    variant: {
      control: { type: "radio" },
      options: ["primary", "success", "transparent"],
    },
    children: {
      control: { type: "text" },
    },
  },
} as ComponentMeta<typeof StyledButton>;

const Template: ComponentStory<typeof StyledButton> = (args) => (
  <StyledButton {...args} />
);

export const TemplateTest = Template.bind({});

TemplateTest.args = {
  variant: "primary",
  children: "primary",
};

アドオン

npm install --save-dev @storybook/addon-essentials

を入れて設定。ここでは document を記載する方法だけ記載する。

//StyledButton.stories.tsx
import MDXDocument from "./StyledButton.mdx";
export default {
  title: "StyledButton",
  component: StyledButton,
  argTypes: {
    //propsに渡すものを定義する
    variant: {
      control: { type: "radio" },
      options: ["primary", "success", "transparent"],
    },
    children: {
      control: { type: "text" },
    },
  },
  //parameter内にdocumnentを記載したmarkdownファイルを入れる
  parameters: {
    docs: {
      page: MDXDocument,
    },
  },
} as ComponentMeta<typeof StyledButton>;


//StyledButton.mdx
import { StyledButton } from "../components/StyledButton";

## StyledButton

StyledButton は atoms です

- primary
- success
- transparent
  を受け取ります。

### primary

tsx
<StyledButton variant="primary">Primary</StyledButton>

コンポーネントのユニットテスト

React Testing laibrary が推奨されている。コンポーネントを実際に描画してみてその結果の DOM にサクセスをして正しく描画されているかをテストする。

npm install --save-dev jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom
//jest.config.js
const nextJest = require("next/jest");

const createJestConfig = nextJest({
  // next.config.jsとテスト環境用の.envファイルが配置されたディレクトリをセット。基本は"./"で良い。
  dir: "./"
});

// Jestのカスタム設定を設置する場所。従来のプロパティはここで定義。
const customJestConfig = {
  testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
  setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],

  testEnvironment: "jsdom"
};

// createJestConfigを定義することによって、本ファイルで定義された設定がNext.jsの設定に反映されます
module.exports = createJestConfig(customJestConfig);

input を test する

//Input.tsx
import { useState } from "react";

type InputProps = JSX.IntrinsicElements["input"] & {
  label: string;
};

export const Input = (props: InputProps) => {
  const { label, ...rest } = props;
  const [text, setText] = useState("");

  const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };
  const resetInputField = () => {
    setText("");
  };
  return (
    <div>
      <label htmlFor={props.id}>{label}</label>
      <input {...rest} type="text" value={text} onChange={onInputChange} />
      <button onClick={resetInputField}>Reset</button>
    </div>
  );
};

import { render, screen, RenderResult } from "@testing-library/react";
import { Input } from "./index";

describe("Input", () => {
  let renderResult: RenderResult;

  beforeEach(() => {
    //Inputを描画する設定
    renderResult = render(<Input id="username" label="Username" />);
  });
  afterEach(() => {
    //各々のtest前に解放
    renderResult.unmount();
  });

  it("正常_描かれたときにから文字になっているか", () => {
    //getByLabelTextを使うことで描画されているDOMから指定した名前のラベルに対応するInputを取得する
    //labelから取得しているため型はInputElementになる(ならければならばい)
    const inputNode = screen.getByLabelText("Username") as HTMLInputElement;

    expect(inputNode).toHaveValue("");
  });
});


//labelタグが存在しない場合、<input aria-label={label} />と書くことで参照できるようになる。

値を入れることができているかの test。react の中でもfireEventを利用することで実装することができる。

it("正常_入力した内容が表示される", () => {
const inputText = "test input text";
const inputNode = screen.getByLabelText("Username") as HTMLInputElement;

//changeイベントを発火させる
fireEvent.change(inputNode, { target: { value: inputText } });

expect(inputNode).toHaveValue(inputText);
});

reset のボタンが押せているか

  it("正常_ボタンを押すことでテキスト内容がresetされる", () => {
    const inputText = "test input text";
    const inputNode = screen.getByLabelText("Username") as HTMLInputElement;

    //changeイベントを発火させる
    fireEvent.change(inputNode, { target: { value: inputText } });

    //ボタンを取得
    //ボタンタグはroleを取得する。defaultでbuttonというroleが入るので取得。第二引数でボタンで表示しているテキストを指定
    const buttonNode = screen.getByRole("button", {
      name: "Reset",
    }) as HTMLButtonElement;

    fireEvent.click(buttonNode);

    expect(inputNode).toHaveValue("");
  });

非同期のテスト

入力したテキストが入力中であるかを表示するコンポーネントをテストする

import React, { useCallback, useRef, useState } from "react";

type DelayButtonProps = {
  onChange: React.ChangeEventHandler<HTMLInputElement>;
};

export const DelayInput = (props: DelayButtonProps) => {
  const { onChange } = props;

  const [isTyping, setIsTyping] = useState(false);
  const [inputValue, setInputValue] = useState("");
  const [viewValue, setViewValue] = useState("");
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const text = isTyping ? "入力中..." : `入力したテキスト:${viewValue}`;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setIsTyping(true);
      setInputValue(e.target.value);

      if (timerRef.current !== null) {
        clearTimeout(timerRef.current);
      }

      timerRef.current = setTimeout(() => {
        timerRef.current = null;
        setIsTyping(false);
        setViewValue(e.target.value);
        onChange(e);
      }, 1000);
    },
    [onChange]
  );
  return (
    <div>
      <input
        data-testid="input-text"
        value={inputValue}
        onChange={handleChange}
      />
      <span data-testid="display-text">{text}</span>
    </div>
  );
};


//test
import { fireEvent } from "@storybook/testing-library";
import { render, screen, RenderResult } from "@testing-library/react";
import { act } from "react-dom/test-utils";
import { DelayInput } from "./index";

describe("DelayInput", () => {
  let renderResult: RenderResult;
  let handleChange: jest.Mock;

  beforeEach(() => {
    jest.useFakeTimers();
    //mock
    handleChange = jest.fn();
    //Inputを描画する設定
    renderResult = render(<DelayInput onChange={handleChange} />);
  });
  afterEach(() => {
    //各々のtest前に解放
    renderResult.unmount();
    jest.useFakeTimers();
  });

  it("正常_テキストが表示されているか", () => {
    const spanNode = screen.getByTestId("display-text") as HTMLSpanElement;

    expect(spanNode).toHaveTextContent("入力したテキスト:");
  });

  it("正常_入力中と表示されているか", () => {
    const inputText = "test input text";
    const inputNode = screen.getByTestId("input-text") as HTMLInputElement;

    fireEvent.change(inputNode, { target: { value: inputText } });

    const spanNode = screen.getByTestId("display-text") as HTMLSpanElement;
    expect(spanNode).toHaveTextContent("入力中...");
  });

  it("正常_入力して1秒後にテキストが表示されるか", async () => {
    const inputText = "test input text";
    const inputNode = screen.getByTestId("input-text") as HTMLInputElement;

    fireEvent.change(inputNode, { target: { value: inputText } });

    //actを実行することで内部でtimeoutを待たずに実行されることを保証する
    await act(() => {
      jest.runAllTimers();
    });

    const spanNode = screen.getByTestId("display-text") as HTMLSpanElement;

    expect(spanNode).toHaveTextContent(`入力したテキスト:${inputText}`);
    expect(handleChange).toHaveBeenCalled();
  });
});

プロフィール
Kobasan
現在都内のWeb会社で働いている人です.主にこれまで,Web関連及び機械学習周りのことをやってきました.このブログではそれらの内容を含む,ちょっとした私の備忘録を記載するものです.