20200114のReactに関する記事は9件です。

[React/state更新]inputタグなどの入力フォームにおけるvalue値を更新

inputタグなど、入力値を変える場合はonChangeイベントでstateを更新する必要がある

空で設定した初期値を更新し、下記のように
name:タスク名,
content:炊事
というようにvalue値を更新できるようにする方法を備忘録として残しておく。

入力フォーム.png

コンポーネント内でStateを定義する

初期値は空文字のため、{name:'', content:''}で設定

    const [newData, setNewData] = useState({name:'', content:''});

先程のnewDataを使用し、value値に使用する
そして後ほど定義するinputChangeをonChangeイベントに設定

        <form>
            <TextField  label="タスク名" name="name" value={newData.name} onChange={inputChange} />
            <TextField label="内容" name="content" value={newData.content} onChange={inputChange} />
            <Button color="primary" variant="contained">登録</Button>
        </form>

コンポーネント内にonChangeイベントのfunctionを定義

keyとvalueを設定し、newDataのkeyと入力フォームのname値が一致している場合にvalueに更新し、新しく作り直したオブジェクトに代入して、setNewDataで、stateの更新を行う。


    function inputChange(e){
        const key = e.target.name;
        const value = e.target.value;
        newData[key] = value;
        let data = Object.assign({}, newData);
        setNewData(data);
     }

以上、let data = Object.assign({}, newData);でオブジェクトを作り直さないとstateが更新されず、再レンダーもされません。
少し詰まりました。

他にもっといいやり方があるかも。。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactにTypeScriptを導入【Tips集】

ReactにTypeScriptを導入したときのTipsをまとめました。
一助となればこれ幸い。
※基礎編レベルになります

手始め

App.tsxで書いてみる

ファイルApp.jsApp.tsxに変更

App.tsx
import React, { FC } from 'react';

const App: FC = ()  => {
  const sum = (a: number, b: number): number => {
    return a + b;
  };
  return (
    <>
      <p>{sum(1, 2)}</p>
    </>
  );
};

export default App;
  • FCはFunctional Component型の指定

Tips

interface

interface Todo {
  text: string;
  complete: boolean;
  name?: string;
  readonly x: number;
}
  • 型の定義をまとめる
  • 関数やオブジェクト、配列をまとめることができる
  • interfaceの名前の頭文字は大文字
  • name?は任意のプロパティ
  • readonlyは読み込み専用、xを変更することができない
let hoge: Hoge = { x: 10 };
hoge.x = 5; // error

参考サイト

継承

interface Todo {
  text: string;
  complete: boolean;
}

interface Todo2 extend Todo {
  tags: string[];
}
  • extendで型を継承
  • string[]は配列型で配列の中身がstring

外部ファイルで管理

export interface Action {
  type: string;
  payload: Hoge[];
}
  • exportで外部ファイルに適用
  • 複数interfaceを1つのファイルで管理可能

分割代入に適用

const { hoge }: { hoge: Data[] } = state;
  • : { hoge: Data[] }で変数hogeの型指定
  • Data[]は配列型で配列の中身がinterfaceのData

useState

パターン1

const [value, setValue] = useState<string>('');
  • useStateに文字列型を指定

パターン2

interface Todo {
  text: string;
  complete: boolean;
}

const [todos, setTodos] = useState<Todo[]>([]);
  • useStateにオブジェクト型を指定
  • Todo[]は配列型で配列の中身がinterfaceのTodo

パターン3

interface State {
  notificationVisible: boolean;
}

const [state, setState] = useState<State>({
  notificationVisible: true
});

const handleClick = () => {
  setState({
    notificationVisible: false
  });
  • Genericsで型指定したときの書き方

propsの型指定

interface HogeProps {
  name: string;
  age: number;
  prefecture?: string;
}

const Hoge: FC<HogeProps> = ({ name, age }) => {

関数に適用

interface Todo {
  text: string;
  complete: boolean;
}

const addTodo = (text: string): void => {
  const newTodos: Todo[] = [...todos, { text, complete: false }];
  setTodos(newTodos);
};
  • : voidはreturn(返り値)がないときに指定

formのonSubmitのEventの型指定

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
};

<form onSubmit={handleSubmit}>
  • formのonSubmit関数の引数にReact.FormEventを指定

formのonChangeのEventの型指定

const [hoge, setHoge] = useState('');

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const { value }: { value: string } = e.target;
  setHoge(value);
};

<input type="text" value={hoge} onChange={handleChange} />
  • onChangeのevent関数の引数にReact.ChangeEvent<HTMLInputElement>)を指定
  • const { value }: { value: string }は分割代入の型指定

mapで展開するときの型指定

interface Todo {
  text: string;
  complete: boolean;
}

<ul>
  {
    todos.map((todo: Todo, index: number) => (
      <li key={index}>{todo.text}</li>
    ))
  }
</ul>

stateとactionに導入

import React, { createContext } from 'react';

const FETCH_DATA: string = 'FETCH_DATA';

interface State {
  hoge: [];
}

interface Hoge {
  name: string;
  age: number;
}

interface Action {
  type: string;
  payload: Hoge[];
}

const initialState:State = {
  hoge: [];
};

const Index = createContext<State>(initialState);

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case FETCH_DATA:
      return { ...state, hoge: action.payload };
    default:
      return state;
  }
};

lazyに適用

import React, { lazy } from 'react';

const Hoge = lazy<any>(() => import('./Hoge'));

dispatchに適用

interface Hoge {
  name: string;
  age: number;
}

interface Action {
  type: string;
  payload: Hoge[];
}

type Dispatch = React.Dispatch<Action>

interface Foo {
  store: { state: State, dispatch: Dispatch }
}
  • storeの型を指定
  • Hoge[]は配列型で配列の中身がinterfaceのHoge
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

moxiosを使用してREST-APIのテストコードを書く

Jestでテストコードを書くときにmockを作ってテストするケースが多いですね。
この記事ではHTTPクライアントのaxiosを使ってREST-APIを実行したときのテストコードについて書きます。

install

$ yarn add axios
$ yarn add -D moxios // moxiosはdevDependenciesで管理

React

  • callback関数を書いています
  • APIのdataを取得したらdispatchでpayloadにdataを渡しています
sammple.js
import axios from 'axios';

React.useEffect(() => { hoge(foo) }, [])

const hoge = async (callback) => {
  const response = await axios.get('http://localhost:3030');
  callback(response.data);
}

const foo = (data) => dispatch({ type: "FUGA", payload: data });

Jest

  • axiosのmock「moxios」を使ってdummyのrequest/responseを記述
  • test前にmoxiosをinstallして終わるとunistallする
  • testコードはasync/awaitで記述
  • moxios.waitのコールバック関数にrequest/responseの記述
sample.test.js
import moxios from 'moxios';

describe('moxios tests', () => {
  beforeEach(() => {
    moxios.install();
  });
  afterEach(() => {
    moxios.uninstall();
  });

  test('calls hoge callback on axios response', async () => {
    const dummy = 'dummy';

    moxios.wait(() => {
      const request = moxios.requests.mostRecent();
      request.respondWith({
        status: 200,
        response: dummy,
      });
    });

    const mock = jest.fn();
    await hoge(mock);

    expect(mock).toHaveBeenCalledWith(dummy);
  });
});
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React Redux Hooks API + Jest + TypeScript でユニットテスト

だいぶ前に書いたReact Redux Hooks API でユニットテストをたまに見てくれる方もいるようですが、Sinonに依存しているサンプルのため、ここではJestで解決する例を取り上げてみます。
また、useSelectorを複数回実行するコンポーネントをテストしたいといったケースにどう対応するかも紹介してみます。

前提

  • Jest: 24.9.0
  • Enzyme: 3.11.0

今回テストするコンポーネントはこちらです。

Counter.tsx
import React, { FC, useCallback } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { RootState } from 'path/to/store'
import { increment } from 'path/to/actions'

export type CounterSelectedState = number

const Counter: FC = () => {
  const count = useSelector<RootState, CounterSelectedState>(
    ({ counter }) => counter,
  )
  const dispatch = useDispatch()
  const handleClick = useCallback(() => {
    dispatch(increment())
  }, [dispatch])

  return (
    <div>
      <span>{count}</span>
      <button onClick={handleClick}>+1</button>
    </div>
  )
}

export default Counter

CounterSelectedState でエイリアスを作っているのは、後でテストを書く際に使うためですが、必要なければなくてもいいです。

Jestを使ってテストを書く

まずテストしたいことを書いてみましょう。

Counter.spec.tsx
import React from 'react'
import { shallow } from 'enzyme'
import Counter from './Counter'
import { increment } from 'path/to/actions'

describe('<Counter />', () => {
  it('dispatches increment action', () => {
    const wrapper = shallow(<Counter />)
    wrapper.find('button').simulate('click')

    expect(wrapper.find('span').text()).toBe('10')
    expect(increment).toBeCalledTimes(1)
  })
})

このままでは当然まだ動きません。

useSelector, useDispatchのスタブ

useSelectoruseDispatchのスタブを用意します。

import React from 'react'
import { shallow } from 'enzyme'
+ import Counter, { CounterSelectedState } from './Counter'
import { increment } from 'path/to/actions'
+ import { useSelector, useDispatch } from 'react-redux'


+ jest.mock('path/to/actions')
+ jest.mock('react-redux')
+ const useSelectorMock = useSelector as jest.Mock<CounterSelectedState>
+ const useDispatchMock = useDispatch as jest.Mock

describe('<Counter />', () => {
+   beforeEach(() => {
+     useSelectorMock.mockReturnValue(10)
+     useDispatchMock.mockReturnValue(jest.fn())
+   })
+   afterEach(() => {
+     jest.resetAllMocks()
+   })

  it('dispatches increment action', () => {
    const wrapper = shallow(<Counter />)
    wrapper.find('button').simulate('click')

    expect(wrapper.find('span').text()).toBe('10')
    expect(increment).toBeCalledTimes(1)
  })
})

Jestでは、jest.mockでモジュールを自動モックすることができます(第2引数にファクトリを指定することができますが、今回は各テストで異なる値を返したい場合に対応するため、別のアプローチをとります)。

jest.mock('react-redux')

TypeScriptでテストを書いていると、mockReturnValueuseSelectorから生えていないので、
力技ですが下記のようにダウンキャストします。
この際、型をjest.Mock<CounterSelectedState>とすることで、返り値の型をCounterSelectedStateとして指定することができます。

const useSelectorMock = useSelector as jest.Mock<CounterSelectedState>
const useDispatchMock = useDispatch as jest.Mock

また今回は、アクションクリエイタが呼ばれたことをテストしているため、合わせて、下記のようにアクションクリエイターに対してもモックしておきます(expect(increment).toBeCalledTimes(1) の部分です)。

jest.mock('path/to/actions')

そして、各テスト前に useSelector が10を返すように設定し、各テスト後にモックをリセットしておきましょう。

beforeEach(() => {
  useSelectorMock.mockReturnValue(10)
  useDispatchMock.mockReturnValue(jest.fn())
})

afterEach(() => {
  jest.resetAllMocks()
})

jest.Mock<CounterSelectedState>としたことで、useSelectorMock.mockReturnValueでは、CounterSelectedStateを返すように補完が効いてくれます。
今回はnumberなので恩恵はあまり感じませんが、オブジェクトを返す場合などに有効です。

これでテストが通ります。

各テストで異なる値を取りたい

上記では1つのテストしか存在しておらず、useSelectorMock.mockReturnValue(10)でカウントが10固定になっていますが、異なる値を扱いたい場合が往々にして存在します。
その場合はテスト内部で直接値を指定できます。

  it('has count as 11', () => {
    useSelectorMock.mockReturnValue(11)

    const wrapper = shallow(<Counter />)
    expect(wrapper.find('span').text()).toBe('11')
  })

複数回のuseSelectorに対応する

複数の値をストアから参照したい場合に、一度のuseSelectorで複数の値をまとめたオブジェクトとして返すのではなく、
useSelectorを複数回実行し、小さな単位で取得することが推奨されています
FYI: Call useSelector Multiple Times in Function Components
つまり、先程のコンポーネントで別のStateの値(ここではsomething.isActiveを例とします)を使う際に、

export type CounterSelectedState = {
  count: number
  isActive: boolean
}

const count = useSelector<RootState, CounterSelectedState>(
  ({ counter, something }) => ({
    count: counter,
    something: something.isActive,
  }),
)

ではなく、

export type CounterSelectedState = number

const count = useSelector<RootState, CounterSelectedState>(
  ({ counter }) => counter,
)
const isActive = useSelector<RootState, boolean>(
  ({ something }) => something.isActive,
)

が望ましいということです。
前者であれば、上記のuseSelectorMock.returnValueで対応可能ですが、
後者は複数回useSelectorを実行しているため、異なるアプローチが必要となります。

色々と対応方法はありますが、簡単にいくつか紹介しておきます。

方法1: mockReturnValueOnceを使う

JestのモックにはmockReturnValueOnceメソッドが存在しており、呼び出し回数に応じて返す値をコントロールすることができます。ただし、useSelectorを呼び出す順番に依存するため、扱いづらさは残ります。

test('mockReturnValueOnceを使った場合', () => {
  useSelectorMock
    .mockReturnValueOnce(11)
    .mockReturnValueOnce(true)
})

※ ライフサイクルが絡む場合のテストなどにおいて、Enzymeのshallowではなくmountをつかってテストを行いたい場合には、mockReturnValueOnceをうまく使うことでHooksの返り値をコントロールすることができますので、こういったユースケースには有効かもしれません。

方法2: mockImplementationを使う

このケースでは、re-ducksなどの設計パターンに見られる、Stateから値を取得するためのselectorと呼ばれる関数を用います。上記の例でいえば、useSelectorに渡しているコールバック関数がそれですが、mockImplementationで参照を比較するため、関数として定義します。

ここでは下記のような実装を想定します。

export function getCount({ counter }: RootState): number {
  return counter
}

export function isSomethingActive({ something }): boolean {
  return something.isActive
}
const count = useSelector<RootState, CounterSelectedState>(getCount)
const isActive = useSelector<RootState, boolean>(isSomethingActive)

テストでは、useSelector.mockImplementationを使い、引数に応じて返り値をコントロールすることができます。
つまり、useSelectorに渡された引数の関数の参照が一致する場合に、それに応じた値を返すということになります。

beforeEach(() => {
  useSelectorMock.mockImplementation(selector => {
    if (selector === getCount) {
      return 10
    } else if (selector = isSomethingActive) {
      return true
    }
  })
})

方法3: カスタムフックを定義する

これが一番ラクです。
useSelectorMockは使わずに、useSelectorを内部で呼び出すカスタムフックを定義し、
テストではこのカスタムフックのスタブを用意することで対応します。

export function useCount() {
  return useSelector<RootState, number>(({ counter }) => counter)
}

export function useIsActive() {
  return useSelector<RootState, boolean>(({ something }) => something.isActive)
}
jest.mock('path/to/hooks')
const useCountMock = useCount as jest.Mock<number>
const useIsActiveMock = useIsActive as jest.Mock<boolean>

beforeEach(() => {
  useCountMock.mockReturnValue(10)
  useIsActive.mockReturnValue(true)
  useDispatchMock.mockReturnValue(jest.fn())
})

かなりシンプルな例ではありますが一助になれば幸いです。
他にも色々と方法がありますが、「おすすめのこんなやり方あるよ」という方はぜひ共有ください:bow:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactタグの作り方

はじめに

備忘録です。
Layoutタグを作ろうかな、と思ったときに以前のコードを見直す必要があったので、
整理とまとめておくために記述します。

もっといい方法があったら教えてください。
タイプミスあったらすみません。

code

Layout.jsxとUseLayout.jsxは同じ階層にあるものとします.
本当は分けた方がいいです...

Typescriptにする場合はChildrenはReactElementreactからimportして割り当ててあげると良い感じで行けます.
children: ReactElement<any>こんな感じで。

Layout.jsx

import React, { Fragment, cloneElement } from "react";

const Layout = props => {
  const { title, children } = props;
  return (
    <Fragment>
      <div>
        {title}
      </div>
      {cloneElement(children)}
    </Fragment>
  )
}

export default Layout;

UseLayout.jsx

import React from "react";
import Layout from "./Layout";

const UseLayout = () => (
  <Layout title="Layoutです">
    <div>
      componentを配置してね
      <button>ぼたんです</button>
    </div>
  </Layout>
)

Dialogを作りたい場合とかイベントを動的に変化させたい場合

material-uiの同じようなDialogを複数ページに適用させたい場合
ただし、イベントは変えたいよって場合
TypeScriptで書いていますが、JavaScriptにしたい場合はpropsを消して、
= ({children, title, ...}) =>= props =>にすればOK

Dialog.tsx

import React, { cloneElement, ReactElement } from "react";
import Dialog from "@material-ui/core/Dialog";
import Button from "@material-ui/core/Button";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";

type Props = {
  children: ReactElement<any>,
  title: string,
  description: string,
  disagree: string,
  agree: string,
  action?: Function | null,
}

const Dialog: React.FC<Props> = ({
  children,
  title,
  description,
  disagree,
  agree,
  action = null,
}) => {
  const [open, setOpen] = React.useState(false);
  return (
    <span>
      {cloneElement(children, {onClick: () => setOpen(true)})}
      <Dialog
        open={open}
        onClose={() => setOpen(false)}
      >
        <DialogTitle>{title}</DialogTitle>
        <DialogContent>
          <DialogContentText>
            {descriotion}
          <DialogContentText>
        </DialogContent>
        <DialogActions>
          <Button onClick={() => setOpen(false)}>
            {disagree}
          </Button>
          <Button onClick={() => {
            action !== null & action()
            setOpen(false);
          }}>
            {agree}
          </Button>
        </DialogActions>
      </Dialog>
    </span>
  )
}

ちょっと解説

上位から受け取ったchildrenはコンポーネントとしてDialog.tsxにもらえるので、

<Layout>
  <Button>Click!</Button>
</Layout>

のように使用できる.

タイトル, OKボタン, NGボタン, 説明は今回は必須の引数としているので

<Layout
  title="test"
  description="OK or NG?"
  disagree="NGボタン"
  agree="OKボタン"
  action={() => concole.log("OK or NGが押されました")}
>
  <Button>Click</Button>
</Layout>

となる.

そしてコンポーネント単位でアクションを設定したいので
actionに実行したい関数を渡してあげるとその関数を実行することが出来る.
react-reduxを使用する場合はactiondispatchを当てて使うこともできます.

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ページネーションと並び替えに対応した投稿一覧画面とAPIの実装【初学者のReact×Railsアプリ開発 第10回】

やったこと

  • Reactでの投稿一覧画面の実装と並び替えに対応するためのRails APIの実装
  • ラジオボタンの変更によるAPIからの投稿の取得と表示
  • reduxを使った表示する投稿の状態管理
  • material-ui-flat-paginationを用いたページネーションの実装

成果物

qkb5r-7d5kg.gif

Rails APIの実装手順

route.rb: ルートの編集

route.rb
Rails.application.routes.draw do
 namespace :api, defaults: { format: :json } do
    namespace :v1 do

      get 'posts', to: 'posts#index'
      get 'posts_suki', to: 'posts#suki_index'
      get 'posts_allcount', to: 'posts#all_count_index'

    end
 end
end

posts_controller

  • API側のページネーションの実装として、kaminariを用いています。
  • ポストは1ページあたり10個ずつ返すようにしています。新着順や投票数順など、order('...')で、postsテーブルのどのカラムで並び替えするかを記述しています。
  • page_lengthは、React側でページ数を何ページまで表示するかを確定させるために必要な情報です。46個の投稿なら5ページまでなど...
posts_controller.rb
     def index
        posts = Post.page(params[:page] ||= 1).per(10).order('created_at DESC')
        page_length = Post.page(1).per(10).total_pages
        json_data = {
          'posts': posts,
          'page_length': page_length,
        }
        render json: { status: 'SUCCESS', message: 'Loaded posts', data: json_data}
      end

      def suki_index
        posts = Post.page(params[:page] ||= 1).per(10).order('suki_count DESC')
        page_length = Post.page(1).per(10).total_pages
        json_data = {
          'posts': posts,
          'page_length': page_length,
        }
        render json: { status: 'SUCCESS', message: 'Loaded posts', data: json_data}
      end

      def all_count_index
        posts = Post.page(params[:page] ||= 1).per(10).order('all_count DESC')
        page_length = Post.page(1).per(10).total_pages
        json_data = {
          'posts': posts,
          'page_length': page_length,
        }
        render json: { status: 'SUCCESS', message: 'Loaded posts', data: json_data}
      end

React実装手順

App.js

  • ルートの編集です。
App.js
import PostsList from './containers/PostsList';

            <Auth>
              <Switch>
                <Route exact path="/" component={Home} />
                <Route path='/create' component={Create} />
                <Route path='/postslist' component={PostsList} />
              </Switch>
            </Auth>

PostsList.js

  • ここでは一部のコードのみ紹介します。
  • 表示させるポストなどの情報は、Redux(PostListReducer)で管理しています。
  • componentdidmountで、初期描画の際に表示させるポストの取得を行っています。前回描画時の情報を保存しておくためにreduxでの状態管理を行っています。
  • handleChangeらラジオボタンの変更に対応しています。
  • handlePaginationClickは、ページリンクの変更に対応しています。offsetは1ページ目なら0、2ページ目をクリックしたときは10、3ページ目なら20...です。この情報で、APIで何ページ目の情報をもらうか確定させています。
Postslist.js
//module import, css部分は省略

class PostsList extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.handlePaginationClick = this.handlePaginationClick.bind(this);
  }

  componentDidMount() {
    const { PostsListReducer } = this.props;
    if (PostsListReducer.selected === "新着順") {
      this.props.actions.getPostsList("", PostsListReducer.offset, "新着順")
    } else if (PostsListReducer.selected === "スキが多い順") {
      this.props.actions.getPostsList("_suki", PostsListReducer.offset, "スキが多い順")
    } else if (PostsListReducer.selected === "投票数が多い順") {
      this.props.actions.getPostsList("_allcount", PostsListReducer.offset, "投票数が多い順")
    }
  }

  handleChange(e) {
    if (e.target.value === "新着順") {
      this.props.actions.getPostsList("", 0, "新着順")
    } else if (e.target.value === "スキが多い順") {
      this.props.actions.getPostsList("_suki", 0, "スキが多い順")

    } else if (e.target.value === "投票数が多い順") {
      this.props.actions.getPostsList("_allcount", 0, "投票数が多い順")
    }
  }

  handlePaginationClick(offset) {
    const { PostsListReducer } = this.props;
    if (PostsListReducer.selected === "新着順") {
      this.props.actions.getPostsList("", offset, "新着順")
    } else if (PostsListReducer.selected === "スキが多い順") {
      this.props.actions.getPostsList("_suki", offset, "スキが多い順")
    } else if (PostsListReducer.selected === "投票数が多い順") {
      this.props.actions.getPostsList("_allcount", offset, "投票数が多い順")
    }
  }

PostsList.js(render)

  • 続いて、レンダーの部分です。
  • PostListReducerの情報を使って、表示を制御しています。
  • 各ポストには、リンク("/posts/post.id")を貼って、詳細ページに飛べるようにしています。
  • RadioGroupタグと、Paginationタグの設定が多少頭を使います。
PostsList.js
  render() {
    const { CurrentUserReducer } = this.props;
    const { PostsListReducer } = this.props;

    const { classes } = this.props;

    return (
      <Scrollbars>
        <div className={classes.container}>
          <FormControl component="fieldset">
            <FormLabel component="legend"></FormLabel>
            <RadioGroup aria-label="position" name="position" value={PostsListReducer.selected} onChange={this.handleChange} row>
              <FormControlLabel
                value="新着順"
                control={<Radio color="primary" />}
                label="新着順"
                labelPlacement="end"
              />
              <FormControlLabel
                value="スキが多い順"
                control={<Radio color="primary" />}
                label="スキが多い順"
                labelPlacement="end"
              />
              <FormControlLabel
                value="投票数が多い順"
                control={<Radio color="primary" />}
                label="投票数が多い順"
                labelPlacement="end"
              />
            </RadioGroup>
          </FormControl>

          <ul className={classes.ul}>
            {PostsListReducer.items.map((post) => (
              <Link to={"/posts/" + post.id} className={classes.link}>
                <li className={classes.li} key={post.id}>
                  <div className={classes.licontent}>
                    <h3 className={classes.lih3}>{post.content}</h3>
                  </div>
                </li>
              </Link>
            ))}
          </ul>
          <MuiThemeProvider theme={pagitheme}>
            <CssBaseline />
            <Pagination
              limit={10}
              offset={PostsListReducer.offset}
              total={PostsListReducer.page_length * 10}
              onClick={(e, offset) => this.handlePaginationClick(offset)}
            />
          </MuiThemeProvider>
        </div>
      </Scrollbars>
    )
  }
}

actions/index.js

  • ここでは、APIから投稿を取得しています。
  • PostsListReducer.jsでstateを変更するためのactionの内容の記述とdispatchをしています。
index.js
export const getPostsList = (fetchlink, offset, selected) => {
  return (dispatch) => {
    dispatch(getPostsListRequest())
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    const page_url = offset / 10 + 1

    return axios.get(process.env.REACT_APP_API_URL + `/api/v1/posts${fetchlink}?page=${page_url}`, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(response => dispatch(getPostsListSuccess(response.data.data.posts, offset, response.data.data.page_length, selected)))
      .catch(error => dispatch(getPostsListFailure(error, offset, selected)))
  };
};

export const getPostsListRequest = () => ({
  type: 'GET_POSTSLIST_REQUEST',
})

export const getPostsListSuccess = (json, offset, page_length, selected) => ({
  type: 'GET_POSTSLIST_SUCCESS',
  items: json,
  offset: offset,
  page_length: page_length,
  selected: selected,
})

export const getPostsListFailure = (error, offset, selected) => ({
  type: 'GET_POSTSLIST_FAILURE',
  items: error,
  offset: offset,
  selected: selected,
})

reducers/PostListReducer.js

  • ここでreduxのstateの変更を行っています。
  • initialStateに記述の通り、初期状態では新着順の1ページ目が表示されるようになっています。
PostListReducer.js
const initialState = {
  isFetching: false,
  items: [],
  offset: 0,
  page_length: 1,
  selected: "新着順",
};

const PostsListReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'GET_POSTSLIST_REQUEST':
      return {
        ...state,
        isFetching: true,
        items: [],
        offset: "",
        page_length: "",
      };
    case 'GET_POSTSLIST_SUCCESS':
      return {
        ...state,
        isFetching: false,
        items: action.items,
        offset: action.offset,
        page_length: action.page_length,
        selected: action.selected,
      };
    case 'GET_POSTSLIST_FAILURE':
      return {
        ...state,
        isFetching: false,
        error: action.error,
        selected: action.selected,
        offset: action.offset,
      };
    default:
      return state;
  }
};

export default PostsListReducer;

reducers/rootReducer.js

  • rootReducerにPostsListReducerを追加しています。
rootReducer.js
import { combineReducers } from 'redux'
import { reducer as formReducer } from 'redux-form'
import { routerReducer } from 'react-router-redux'
import CurrentUserReducer from './CurrentUserReducer'
import PostsListReducer from './PostsListReducer'

const rootReducer = combineReducers({
  CurrentUserReducer,
  form: formReducer,
  router: routerReducer,
  PostsListReducer
})

export default rootReducer
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

別ドメインの画像ファイルをローカルに保存する(React+canvas)

Reactでcanvasを使用して「ボタンを押したら画像をダウンロードする機能」を実装しようとしたら詰まったので備忘録
かなりニッチなニーズ

aタグによる画像のダウンロード

通常aタグにdownload属性をつければファイルは簡単にダウンロードできるが

<a href="https://hoge.com/sample.png" download="saved.png">ダウンロード</a>

のように別ドメインのサーバーに存在する画像を指定すると別タブで開くだけでダウンロードされない
aタグのdownload属性は同一オリジンでのみ動作するので別ドメインの画像はダウンロードできない

canvasを使用してダウンロード

canvasを使用して画像をblobに変換してダウンロードする方法

    const c = document.getElementById('canvas');
    c.toBlob((blob) => {
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        document.body.appendChild(a);
        a.download = 'foo.png';
        a.href = url;
        a.click();
    }, 'image/png');

こんな感じでblobに変換してダウンロードすることが可能なのでこちらを試してみる

しかし、、

Tainted canvases may not be exported

こんな感じのエラーが発生

キャンバスは別オリジンの画像を入れると汚染されてしまいtoBlobなどのメソッドが使えなくなるそう

crossOrigin="anonymous"

canvas内のimgタグに crossOrigin="anonymous" をつけると別オリジン間でのダウンロードが可能になる

余計な画像のキャッシュ

crossOrigin="anonymous" をつけたらダウンロードできるようになる場合とならない場合がある

crossOrigin属性を付ける前に画像のリクエストを送っている場合、キャッシュを保存している可能性がある
特にS3に画像を保存している場合、S3は Orgin ヘッダが含まれていないと Access-Control- 系のヘッダを返さない
crossOrigin属性を付ける前のimageタグではOriginヘッダを送信せずにオリジン許可が得られずに怒られる可能性がある

<img src="https://hoge.com/sample.png?cache=none" crossOrigin="anonymous" alt=""/>

のようにすればキャッシュの問題も解決できる
S3の場合はこれでキャッシュの削除ができたが他のサーバーではできないこともあったので他の方法を検討したほうが良さそう

Reactで画像ダウンロード機能を実装してみる

今回は2枚の画像を同時にダウンロードする機能を作成する

まずはカスタムフック

enhance.ts
import { useRef, RefObject } from 'react';

const useEnhancer = () => {
  const canv = useRef<HTMLCanvasElement>(null);
  const canv2 = useRef<HTMLCanvasElement>(null);
  const img = useRef<HTMLImageElement>(null);
  const img2 = useRef<HTMLImageElement>(null);

  const downloadImage = (
    canvas: RefObject<HTMLCanvasElement>,
    image: RefObject<HTMLImageElement>,
    id: string
  ) => {
    if (canvas.current !== null) {
      const currentCnavas = canvas.current;
      const ctx = currentCnavas.getContext('2d');
      if (ctx && image.current !== null) {
        const currentImage = image.current;
        currentCnavas.width = currentImage.width;
        currentCnavas.height = currentImage.height;
        ctx.drawImage(currentImage, 0, 0, currentImage.width, currentImage.height);
      }
      const anchor: HTMLAnchorElement = document.createElement('a');
      currentCnavas.toBlob((blob: any) => {
        if (anchor !== null && blob) {
          anchor.href = window.URL.createObjectURL(blob);
          anchor.download = `${id}.png`;
          anchor.click();
        }
      });
    }
  };

  return {
    downloadImage,
    canv,
    canv2,
    img,
    img2,
  };
};

export default useEnhancer;

View

index.tsx
import React from 'react';
import useEnhancer from './enhance';

const DownloadImage = () => {
  const enhance = useEnhancer();

  return (
    <div>
      <button
        onClick={() => {
          enhance.downloadImage(enhance.canv, enhance.img, '1');
          enhance.downloadImage(enhance.canv2, enhance.img2, '2');
        }}
        type="button"
      >
        ダウンロード
      </button>
      <canvas ref={enhance.canv} style={{ display: 'none' }}>
        <img
          ref={enhance.img}
          src="https://cdn.qiita.com/assets/qiita-fb-fe28c64039d925349e620ba55091e078.png?cache=none"
          alt=""
          crossOrigin="anonymous"
        />
      </canvas>
      <canvas ref={enhance.canv2} style={{ display: 'none' }}>
        <img
          ref={enhance.img2}
          src="https://cdn.qiita.com/assets/qiita-fb-2887e7b4aad86fd8c25cea84846f2236.png?cache=none"
          alt=""
          crossOrigin="anonymous"
        />
      </canvas>
    </div>
  );
};

1.gif

これにて一件落着

参考

Google Chromeでのamazon S3画像へのクロスドメイン接続: stackoverflow

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React + TypeScriptのアプリケーションにStorybook + Storyshotsを導入する

React + TypeScriptのアプリケーションの環境を構築していて、StorybookとStoryshotsの設定でハマったところのメモ。

環境

React 16.12.0
typescript 3.7.4
webpack 4.41.5
storybook 5.2.8

最初create-react-appでやっていたが、storyshotの導入でwebpackやjestの設定を変える必要が出てきたので、使わないで構築し直した。

storybookの導入

セットアップ

package.json

devDependenciesに以下を追加してインストール

package.json
    "@storybook/addon-actions": "5.2.8",
    "@storybook/addon-links": "5.2.8",
    "@storybook/addons": "5.2.8",
    "@storybook/preset-typescript": "1.2.0",
    "@storybook/react": "5.2.8",

scriptsにスクリプトを追加

package.json
    "storybook": "start-storybook -p 6006",

/.storybook/config.jsを追加

/.storybook/config.js
import { configure } from '@storybook/react';

const req = require.context('../src/', true, /\.stories\.(?:js|ts)x?$/);
configure(req, module);

/.storybook/webpack.config.js を追加

ストーリーをTypeScriptで書くには、 /.storybook/ 内にwebpack.config.jsを追加する。

アプリケーションのwebpack設定を引き継ぎたい場合は
https://storybook.js.org/docs/configurations/custom-webpack-config/#using-your-existing-config
のように書くと短く書ける。

ストーリーがtsを使っている場合は、

/stories/webpack.config.js
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
  },

の設定も引き継がないと、import文のpath解決がうまく行かなくてちょっとハマった。
stories.tsxで

SomeComponent.stories.tsx
import AppHeader from '..'; //同階層のindex.tsxをインポートしている

のが、

Can't resolve '..'

と言われてしまう。

なので
/.storybook/webpack.config.js はこのようになった。

/.storybook/webpack.config.js
const path = require('path');
// your app's webpack.config.js
const custom = require('../webpack.config.js');

module.exports = async ({ config, mode }) => {
  return {
    ...config,
    resolve: { ...config.resolve, extensions: custom.resolve.extensions },
    module: { ...config.module, rules: custom.module.rules }
  };
};

Storyshotsの導入

require-context-hookの設定

セットアップは
https://storybook.js.org/docs/testing/structural-testing/#using-storyshots
の通りだが、

require.context

の部分でエラーが出てしまうので、require-context-hookの設定をする。

devDependenciesに追加するのはこの辺

package.json
    "jest": "24.9.0",
    "react-test-renderer": "16.12.0",
    "@storybook/addon-storyshots": "5.2.8",
    "@types/jest": "24.0.25",
    "babel-plugin-require-context-hook": "1.0.0",

.babelrcにテスト用の設定を追加

  "env": {
    "test": {
      "plugins": [
        "require-context-hook"
      ]
    }
  }

/.storybook/config.js のrequire.contextの行の前に以下を足す。

/.storybook/config.js
if (process.env.NODE_ENV === 'test') {
  require('babel-plugin-require-context-hook/register')();
}

【追記】 storybook 5.3.1 にアップデートしたらrequire.contextを使わない記述になっていたので、ここの設定は不要っぽい :innocent:

cssや画像のインポートでのエラーを解決する

ストーリーの中でcssや画像をインポートしていると、storyshotsの実行時にエラーが出る。

Jest encountered an unexpected token
This usually means that you are trying to import a file which Jest cannot parse, e.g. it's not plain JavaScript.

https://jestjs.io/docs/en/webpack#handling-static-assets
この通りにmoduleNameMapperを使用して解決できる。

スナップショットファイルをストーリーごとに分ける

デフォルトでは全てのストーリーのスナップショットが1ファイルに書きだされる。
スナップショットはストーリーごとに分かれた方が圧倒的に差分が見やすいので、分けたい。
そこでmultiSnapshotWithOptionsというオプションを指定する。
https://github.com/storybookjs/storybook/tree/master/addons/storyshots/storyshots-core#multisnapshotwithoptionsoptions

Storyshots.test.jsをこのように書き換える。

Storyshots.test.js
import initStoryshots, {
  multiSnapshotWithOptions,
} from '@storybook/addon-storyshots';

initStoryshots({
  test: multiSnapshotWithOptions(),
});

jestのconfigにはtransformの設定をする。

    "transform": {
      "^.+\\.stories\\.tsx$": "@storybook/addon-storyshots/injectFileName",
      "^.+\\.jsx?$": "babel-jest",
      ".+\\.tsx?$": "ts-jest"
    }

これで、各ストーリーのフォルダにスナップショットが作成される。 :thumbsup:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React hooks 機能紹介

Reactを使った開発をし始めたので、機能たちを紹介しようと思います。

React hooksにはいろんな機能があってとてもロジックを組みやすい

個人的な見解ではありますが、僕はそう感じました。
前バージョンの段階では、よく目にしたのは複雑だったりなんか難しそうだったり、なんか開発者向きなんでしょ?的なのがあったかと思いますが、React hooksが正式リリースして利用してみるとあれって?ってくらい使いやすいわかりやすいと思いました。

巨頭と行ってもいいであろう機能を紹介します。

下記でとりあえずImportします。

import React, { useState, useEffect, useCallback } from 'react';

下記が主に使用したHoooksたちになります。

useState

  const [state, setState] = useState()

useEffect

  useEffect(() => {

  },[]);

useMemo

  const メモ名 = useMemo(() => {

  },[]);

useCallback

 const コールバック名 = useCallback(() => {

 },[]);

useMemoとuseCallbackについては、なかなかはじめ理解にしにくいかと思いますが、ポイントはメモされた関数の返却かメモされた値の返却かなのかなと思います。個人的に腑に落ちたのは返却のタイミング(メモのタイミング)が違うってこと思ったことです。

[ ]←こちらについては、簡単に説明しますと対象です。
対象が、[a , b]のようにあるとそれぞれのタイミングで何かしらのa , bを処理できます。
くらしくは、フック API リファレンスを参考にどうぞ。

簡単なまとめではありますが、以上になります。
他にも、様々な機能がありますので、ぜひ利用してみてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む