20200731のReactに関する記事は16件です。

Gatsby+TypeScriptを快適にするためのgatsby-plugin-graphql-codegenの設定

Gatsby + TypeScript の構成で GraphQL の型定義を自動生成するための gatsby-plugin-graphql-codegen というライブラリがあります。
これを使用すると GraphQL から取得したデータに自動で型が付与されてかなり快適に開発できるようになるのですが、一部困ったことが出てきたので、概要と解決策を記事にまとめます。

ちなみに Gatsby + TypeScript の環境を構築するにはこちらの記事が非常に参考になります。
Gatsby.js を完全TypeScript化する - Qiita

環境

  • TypeScript 3.9.7
  • React 16.13.1
  • Gatsby 2.4.13
  • gatsby-plugin-graphql-codegen 2.7.1

結論

結論から言うと、gatsby-config.js(.ts)でプラグインを読み込む際に以下の設定をすると幸せになれます。

gatsby-config.js
module.exports = {
  plugins: [
    // ...中略
    {
      resolve: 'gatsby-plugin-graphql-codegen',
      options: {
        codegenConfig: { maybeValue: 'T | undefined' }, // これを追加!
      },
    },
  ]
}

どういうこと?

gatsby-plugin-graphql-codegenは、GraphQL クエリから型生成する際、全ての戻り値を以下のMaybeという型でラップします。

graphql-types.ts
export type Maybe<T> = T | null;

GraphQL から取得するデータはnullになる可能性があるので、これは妥当な型定義ではあります。
TypeScript では Optional Chaining?.を利用することで、このような Nullable な値に対しても安全に値を取得できることができます。

Gatsby のデフォルトスターター gatsby-starter-default を例にとると、以下のように GraphQL クエリで画像を読み込んでいるコンポーネントがあります。

src/components/image.js
import React from "react"
import { useStaticQuery, graphql } from "gatsby"
import Img from "gatsby-image"

const Image = () => {
  const data = useStaticQuery(graphql`
    query {
      placeholderImage: file(relativePath: { eq: "gatsby-astronaut.png" }) {
        childImageSharp {
          fluid(maxWidth: 300) {
            ...GatsbyImageSharpFluid
          }
        }
      }
    }
  `)

  return <Img fluid={data.placeholderImage.childImageSharp.fluid} />
}

export default Image

これを TypeScript 化すると以下のように書くことができます。

src/components/image.tsx
import React from "react"
import { useStaticQuery, graphql } from "gatsby"
import Img from "gatsby-image"
import { ImageQuery } from "../../graphql-types"

const Image: React.FC = () => {
  const data = useStaticQuery<ImageQuery>(graphql`
    query Image {
      placeholderImage: file(relativePath: { eq: "gatsby-astronaut.png" }) {
        childImageSharp {
          fluid(maxWidth: 300) {
            ...GatsbyImageSharpFluid
          }
        }
      }
    }
  `)

  return <Img fluid={data.placeholderImage?.childImageSharp?.fluid} />
}

export default Image

gatsby-plugin-graphql-codegenは、queryに名前を付けると(上の場合query ImageImageQueryという名前でgraphql-types.tsに型定義を自動生成してくれます。
それをuseStaticQueryに型指定してあげることでdataが型補完されます。
Imgコンポーネントに対しては Optional Chainging を使用して値を渡すことで、画像が取得できなかった場合でも実行時エラーにならずに処理してくれます。

起きたこと

しかしここでtsconfig.jsonの指定によっては1以下のようなコンパイルエラーが発生します。

この呼び出しに一致するオーバーロードはありません。
  2 中 1 のオーバーロード, '(props: Readonly<GatsbyImageProps>): GatsbyImage' により、次のエラーが発生しました。
    型 'Pick<ImageSharpFluid, "base64" | "aspectRatio" | "src" | "srcSet" | "sizes"> | null | undefined' を
型 'FluidObject | FluidObject[] | undefined' に割り当てることはできません。
      型 'null' を型 'FluidObject | FluidObject[] | undefined' に割り当てることはできません。
  2 中 2 のオーバーロード, '(props: GatsbyImageProps, context?: any): GatsbyImage' により、次のエラーが発生しました。
    型 'Pick<ImageSharpFluid, "base64" | "aspectRatio" | "src" | "srcSet" | "sizes"> | null | undefined' を
型 'FluidObject | FluidObject[] | undefined' に割り当てることはできません。
      型 'null' を型 'FluidObject | FluidObject[] | undefined' に割り当てることはできません。

要点を抜き出すと「nullundefinedに割り当てることはできません」と言っています。
gatsby-imageコンポーネントのImgundefinedを受け付けるように型定義されているのですが、nullは受け取ってくれないようです。
先程述べた通りgatsby-plugin-graphql-codegenは全ての型をMaybe<T> = T | nullでラップするので、data.placeholderImage?.childImageSharp?.fluidnullになりうると判断されてしまいます。
これは、以下のようにnullだったらundefinedになるように書けば回避できます。

src/components/image.tsx
const Image: React.FC = () => {
  // ...略
  return <Img fluid={data.placeholderImage?.childImageSharp?.fluid ?? undefined} />
}

export default Image

ただ正直 GraphQL から取得したデータ全てに対してこれを行うのは結構大変です。特に React コンポーネントの Optional なpropsの定義はT | undefinedであることが多いので、結構な頻度でnull -> undefinedの変換が発生してきます。

Maybe の型を変える

そこで冒頭の結論に戻りますが、プラグイン読み込み時に以下の設定を記載します。

gatsby-config.js
module.exports = {
  plugins: [
    // ...中略
    {
      resolve: 'gatsby-plugin-graphql-codegen',
      options: {
        codegenConfig: { maybeValue: 'T | undefined' }, // これを追加!
      },
    },
  ]
}

codegenConfig.maybeValueで、生成されるMaybeの定義をオーバーライドできます。
T | nullの代わりにT | undefinedとすることで、Nullable な値がundefinedに統一され、シンプルな Optional Chaining だけで書けるようになります。

src/components/image.tsx
const Image: React.FC = () => {
  // ...略
  return <Img fluid={data.placeholderImage?.childImageSharp?.fluid} />
}

export default Image

型定義を変えて大丈夫なの?

nullを勝手にundefinedと扱ってしまって大丈夫なのかと思うかもしれませんが、基本的に問題はないと思っています。
型定義を変えるだけなので、少なくともトランスパイル後の JavaScript には影響はありません。
最近のライブラリならnullundefinedの違いで大きな問題が起こることはないと思っていますが、全てを確認したわけではないです。
ダメだった場合はごめんなさい。

まとめ

  • gatsby-plugin-graphql-codegenの生成するnullに悩まされている方はcodegenConfig: { maybeValue: 'T | undefined' }を設定すると快適になるかもしれません

  1. "strict": trueにしている等 

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

React Contextのメモ

親コンポーネントのstateの値を、孫コンポーネントのpropsの値として使う場合はこのような書き方になる。

index.js
import React from "react";
import ReactDOM from "react-dom";

/* 親 -> 子 -> 孫 -> .. の順に値を渡すことをバケツリレーと呼ぶ(親のstateの値を孫やひ孫のpropsにする) */
class Grandchild extends React.Component {

    render() {
        return(
            <div>{this.props.text}</div>
        )
    }

}

class Child extends React.Component {

    render() {
        return(
            <Grandchild text={this.props.text} />
        )
    }

}

class Parent extends React.Component {

    constructor(props) {
        super(props);
        this.state = {"text": "まんち"};
    }

    render() {
        return(
            <div><Child text={this.state.text} /></div>
        )
    }

}

ReactDOM.render(<Parent />, document.getElementById("app"));

バケツリレーをせずに、親コンポーネントから直接孫やひ孫のコンポーネントに値を渡すのがContext(子コンポーネントにも渡せる)
関数コンポーネントの場合はpropsが存在しないのでこの書き方一択になる?

index.js
import React, {useState, createContext, useContext} from "react";
import ReactDOM from "react-dom";

var Context = createContext(); 

var GrandChild = function() {

    //受け取った値が文字列ならその文字列が、配列ならその配列がそのまま渡される
    var arr = useContext(Context); 
    return(
        <div>
            <p>{arr[0]}</p>
            <p>{arr[1]}</p>
        </div>
    )

}

var Parent = function() {

    var [cnt, addCnt] = useState(0);
    var [numState, switchNumState] = useState("偶数");

    function onClick(num) {
        num += 1;
        addCnt(num);
        if (num % 2 === 0) {
            numState = "偶数";
        } else {
            numState = "奇数";
        }
        switchNumState(numState);
    }

    return(
        <>
          <div>
            {/* providerコンポーネントの作成 value=渡す値 渡す値は文字数字などの単体の値やオブジェクトでも良い */}
            <Context.Provider value={[numState, cnt]}>
                <GrandChild />
            </Context.Provider>
            <input type="button" value="連打しろ!!" onClick={() => onClick(cnt)} />
          </div>
        </>
    )

}

ReactDOM.render(<Parent />, document.getElementById("app"));
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

useImperativeHandleをtypescriptで使う

何回か使ったことがあるんですが、使うたびに型の付け方忘れるのでメモ

公式には使わない方がいいって書いてあるんですけど、簡単に実装したいときは使っちゃってます

簡単な説明

公式

useImperativeHandle は ref が使われた時に親コンポーネントに渡されるインスタンス値をカスタマイズするのに使います。
いつもの話ですが、ref を使った手続き的なコードはほとんどの場合に避けるべきです。
useImperativeHandle は forwardRef と組み合わせて使います

refの値をカスタマイズできるらしいですね

サンプル

まずはuseImperativeHandleを実装する方

ログを出すgreetingという関数を持ったハンドラーを作ります

公式のサンプルのような形での実装は型の実装がめんどくさくなるので、クラスを作って型定義と実装を一度で済ましています

ForwardRefRenderFunctionの第一型引数とuseImperativeHandleの第二引数の返り値の型が合わせられれば実装はなんでもいいです

class Handler {
  private name: string = "";
  constructor(name: string) {
    this.name = name;
  }

  greeting() {
    console.log(`Hello ${this.name}!`);
  }
}

type Props = {}

const Greeting: React.ForwardRefRenderFunction<Handler,Props> = (props, ref) => {
  const [value, setValue] = useState("");

  useImperativeHandle(ref, () => new Handler(value), [value]);

  return (
    <input
      value={value}
      onChange={(e) => {
        setValue(e.target.value);
      }}
    />
  );
};

export const HandleableGreeting = forwardRef(Greeting);

対して使う方

ElementRefHandleableGreetingからHandlerの型を取り、それをuseRefの引数とします

するとhandlerRef.currentにはHandler型が指定されます

const App: React.FC = (props) => {
  const handlerRef = useRef<ElementRef<typeof HandleableGreeting>>(null);
  return (
    <div>
      <HandleableGreeting ref={handlerRef} />
      <button
        onClick={() => {
          if (!handlerRef.current) return;
          handlerRef.current.greeting();
        }}
      >
        押せ!
      </button>
    </div>
  );
};


という形で型がつけられます

解説

ElementRefから解説して行きます

type ElementRef<
    C extends
        | ForwardRefExoticComponent<any>
        | { new (props: any): Component<any> }
        | ((props: any, context?: any) => ReactElement | null)
        | keyof JSX.IntrinsicElements
> =
    // need to check first if `ref` is a valid prop for ts@3.0
    // otherwise it will infer `{}` instead of `never`
    "ref" extends keyof ComponentPropsWithRef<C>
        ? NonNullable<ComponentPropsWithRef<C>["ref"]> extends Ref<
            infer Instance
        >
            ? Instance
            : never
        : never;

ElementRefの型引数の制約により、コンポーネントだけ引数として許すようになっており、そのコンポーネントがrefというpropsを受け取れるならそのrefの型を返すということになってます

ここで気になるのが、Greetingコンポーネントの型です

サンプルでは次のように定義しました

const Greeting: React.ForwardRefRenderFunction<Handler, Props> = (props, ref) => {

...

}

このようにpropsの型には{ ref: Handler }を入れておらず、さらには実装では第二引数でrefを受け取るようになってます

ではどこでpropsの型に{ ref: Handler }が入ったのかというと、それはforwardRefの中です

function forwardRef<T, P = {}>(render: ForwardRefRenderFunction<T, P>): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;

interface ExoticComponent<P = {}> {
    /**
        * **NOTE**: Exotic components are not callable.
        */
    (props: P): (ReactElement|null);
    readonly $$typeof: symbol;
}

ここでforwardRefの返り値であるForwardRefExoticComponentの型引数にpropsとtype RefAttributes<T> = {ref?: T}の交差型が渡されています

ForwardRefExoticComponentの継承を遡ると最終的にExoticComponentに行くのですが、そこで先ほどの型引数がPとして渡されることで、propsがrefを持つように変わります

このようにして、refHandler型として扱うようになり、ElementRefでその型を取ってきているというわけです

まとめ

というわけで、useImperativeHandleを使ったコンポーネントの型のつけ方でした

がっつり調べたのでもう忘れないと思います

多分

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

【VSCode】ES6記法スニペットのEmmet展開方法まとめ

React学習中の自分のための、ES6記法スニペットのEmmet展開方法メモ

Reactの学習を始めたのですが、アロー関数やらimportとかexportとか何度も繰り返し登場する記述を毎回書くのが面倒で、スニペットのVSCode拡張機能ないかな?と思ったら案の定ありました。

VSCodeの拡張機能 : JavaScript (ES6) code snippets

ただこいつを使いこなすにもEmmetのキーを覚えないといけなくて、なかなか覚えられないので自分のためのメモ帳代わりにしようと記事投稿しています。

最初はとりあえず少数しかない状態で公開しちゃいますが、React含めES6記法のEmmetをこれからどんどん追加していきます。
なんならこの記事をご覧のみなさんからも「こんなのあるよ!」「こっちの方がもっと便利だよ!」等ありましたらコメント頂けますと幸いです!(他力本願)

みなさんのお力を借りてブラッシュアップしていきたい!そんな心つもりでございます!

みなさま、どうぞよろしくお願いいたします。

アロー関数 系

nfn

//nfn
const name = (params) => {

}

anfn

//anfn
(params) => {

}

import 系

imr

//imr
import React from 'react'

imrd

//imrd
import ReactDOM from 'react-dom'

imd

//imd
import {  } from 'module'

imp

//imp
import moduleName from 'module'

export 系

exp

//exp
export default 

React Hooks 系

useState

//useState
const [state, setstate] = useState(initialState)

useEffect

//useEffect
useEffect(() => {
    effect
    return () => {
        cleanup
    }
}, [input])

useReducer

//useReducer
const [state, dispatch] = useReducer(reducer, initialState, init)

useContext

//useContext
const context = useContext(contextValue)

おわりに

もっともっとあるはず。これからどんどん追加していきます。

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

SharePointのWebパーツをReactベースで作る

はじめに

SharePointのWebパーツをReactで作れるらしいので、実際にやってみました。しばらくMSから離れていたのですが、私の知っている鎖国のような時代は終わっていて、オープンソースワールドがそこには広がっていました。MSでReactやgulpを使う日が来るなんて…。これをきっかけに、またいろいろと作ってみたくなりました。

Office UI Fabric React

Webパーツ自体は、JavaScriptフレームワークを使っても使わなくてもよいです。が、Reactベースの素晴らしすぎるUIフレームワークがあったので、これを使います。このコンポーネントを使うだけで、簡単にそれっぽいものが作れそうです。
office-ui-fabric-sample.png

今回作るWebパーツ

せっかくなので、SharePointのサイトコンテンツにアクセスするものを作ろうと思います。今回は、リストデータを取得して表示するパーツを作ります。使うコンポーネントは、DetailsListです。

detailslist-sample.png

やってみた

0. 事前準備

こちらを参照して、開発環境のセットアップをしておきます。

1. 新しいWebパーツプロジェクトの作成

次のコマンドを実行して、自分の好きな場所に新しいプロジェクトを作成します。

mkdir detailslist-webpart
cd detailslist-webpart
yo @microsoft/sharepoint

対話形式で必要な情報をインプットします。
以下は明示的に指定して、それ以外はデフォルトの設定としました。

  • What is your solution name? detailslist-webpart
  • Which baseline packages do you want to target for your component(s)? SharePoint Online only (latest)
  • Where do you want to place the files? Use the current folder
  • Which type of client-side component to create? WebPart
  • What is your Web part name? DetailsListSample
  • Which framework would you like to use? React create-new-project-1.png このようになれば、プロジェクトの作成は完了です。 create-new-project-2.png

2. Office UI Fabricコンポーネントの追加

Office UI Fabric Reactコンポーネントを追加します。
src/webparts/detailsListSample/components/DetailsListSample.tsxを開きます。
まず、1行目から4行目までを次のように置き換えます。

import * as React from 'react';
import { IDetailsListSampleProps } from './IDetailsListSampleProps';
import { Announced } from 'office-ui-fabric-react/lib/Announced';
import { TextField, ITextFieldStyles } from 'office-ui-fabric-react/lib/TextField';
import { DetailsList, DetailsListLayoutMode, Selection, IColumn } from 'office-ui-fabric-react/lib/DetailsList';
import { MarqueeSelection } from 'office-ui-fabric-react/lib/MarqueeSelection';
import { Fabric } from 'office-ui-fabric-react/lib/Fabric';
import { mergeStyles } from 'office-ui-fabric-react/lib/Styling';

const exampleChildClass = mergeStyles({
  display: 'block',
  marginBottom: '10px',
});

const textFieldStyles: Partial<ITextFieldStyles> = { root: { maxWidth: '300px' } };

export interface IDetailsListItem {
  key: number;
  name: string;
  value: number;
}

export interface IDetailsListSampleState {
  items: IDetailsListItem[];
  selectionDetails: string;
}

次に、DetailsListSampleクラスも編集します。

export default class DetailsListSample extends React.Component<IDetailsListSampleProps, IDetailsListSampleState> {
  private _selection: Selection;
  private _allItems: IDetailsListItem[];
  private _columns: IColumn[];

  constructor(props: IDetailsListSampleProps) {
    super(props);

    this._selection = new Selection({
      onSelectionChanged: () => this.setState({ selectionDetails: this._getSelectionDetails() }),
    });

    // Populate with items for demos.
    this._allItems = [];
    for (let i = 0; i < 200; i++) {
      this._allItems.push({
        key: i,
        name: 'Item ' + i,
        value: i,
      });
    }

    this._columns = [
      { key: 'column1', name: 'Name', fieldName: 'name', minWidth: 100, maxWidth: 200, isResizable: true },
      { key: 'column2', name: 'Value', fieldName: 'value', minWidth: 100, maxWidth: 200, isResizable: true },
    ];

    this.state = {
      items: this._allItems,
      selectionDetails: this._getSelectionDetails(),
    };
  }

  public render(): JSX.Element {
    const { items, selectionDetails } = this.state;

    return (
      <Fabric>
        <div className={exampleChildClass}>{selectionDetails}</div>
        <Announced message={selectionDetails} />
        <TextField
          className={exampleChildClass}
          label="Filter by name:"
          onChange={this._onFilter}
          styles={textFieldStyles}
        />
        <Announced message={`Number of items after filter applied: ${items.length}.`} />
        <MarqueeSelection selection={this._selection}>
          <DetailsList
            items={items}
            columns={this._columns}
            setKey="set"
            layoutMode={DetailsListLayoutMode.justified}
            selection={this._selection}
            selectionPreservedOnEmptyClick={true}
            ariaLabelForSelectionColumn="Toggle selection"
            ariaLabelForSelectAllCheckbox="Toggle selection for all items"
            checkButtonAriaLabel="Row checkbox"
            onItemInvoked={this._onItemInvoked}
          />
        </MarqueeSelection>
      </Fabric>
    );
  }

  private _getSelectionDetails(): string {
    const selectionCount = this._selection.getSelectedCount();

    switch (selectionCount) {
      case 0:
        return 'No items selected';
      case 1:
        return '1 item selected: ' + (this._selection.getSelection()[0] as IDetailsListItem).name;
      default:
        return `${selectionCount} items selected`;
    }
  }

  private _onFilter = (ev: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, text: string): void => {
    this.setState({
      items: text ? this._allItems.filter(i => i.name.toLowerCase().indexOf(text) > -1) : this._allItems,
    });
  }

  private _onItemInvoked = (item: IDetailsListItem): void => {
    alert(`Item invoked: ${item.name}`);
  }
}

ここまでできたら、いったんローカル実行をしてみます。次のコマンドを実行すると、localhost:4321のローカルワークベンチでWebパーツをプレビューすることができます。

gulp serve

無事、DetailsListコンポーネントが表示されました。
office-ui-fabric-1.png

3. モックストアの作成

今回のゴールは、SharePointサイトのリストデータを表示することですが、ローカルワークベンチでもテストできるように、モックストアを作成します。

リストデータを操作するための、リストモデルを作成します。ここは、実際のSharePointサイトでリスト定義を確認して、適宜モデルを修正してください。今回は、このようなモデルにします。

export interface IDetailsListItems {
  value: IDetailsListItem[];
}

export interface IDetailsListItem {
  key: number;
  name: string;
  value: number;
}

src/webparts/detailsListSample/components/MockHttpClient.tsという名前で、新規ファイルを作成します。

import { IDetailsListItem } from './DetailsListSample';

export default class MockHttpClient  {
  private static _items: IDetailsListItem[] = [{ key: 1, name: 'Mock List 1', value: 1 },
                                        { key: 2, name: 'Mock List 2', value: 2 },
                                        { key: 3, name: 'Mock List 3', value: 3 }];

  public static get(): Promise<IDetailsListItem[]> {
    return new Promise<IDetailsListItem[]>((resolve) => {
      resolve(MockHttpClient._items);
    });
  }
}

src/webparts/detailsListSample/components/DetailsListSample.tsxに戻って、MockHttpClientモジュールをインポートします。

import MockHttpClient from './MockHttpClient';

クラス内でリストデータを取得するプライベートメソッドを追加します。

private _getMockListData(): Promise<IDetailsListItems> {
  return MockHttpClient.get()
    .then((data: IDetailsListItem[]) => {
      var listData: IDetailsListItems = { value: data };
      return listData;
    }) as Promise<IDetailsListItems>;
}

4. リストデータを取得

SharePointワークベンチでは、EnvironmentTypeモジュールを使って、Webパーツを実行している環境に応じて、接続先を簡単にスイッチすることができます。これを使って、ローカル環境の場合はモックストアから、SharePointサイトの場合は実際のリストを取得できるようにします。

src/webparts/detailsListSample/components/DetailsListSample.tsxで、EnvironmentTypeモジュールをインポートします。

import {
  Environment,
  EnvironmentType
} from '@microsoft/sp-core-library';

ワークベンチがSharePointでホストされている場合は、ページのコンテキストにアクセスして、サイトのURLを取得する必要があります。また、SharePoint Frameworkには、spHttpClientというSharePoint APIを呼び出すためのヘルパークラスが含まれており、これを使ってリストにアクセスします。

PropsでサイトのURLとspHttpClientをコンポーネントに与えてあげます。
src/webparts/detailsListSample/components/IDetailsListSampleProps.tsを次のように書き換えます。

import { SPHttpClient } from "@microsoft/sp-http";
export interface IDetailsListSampleProps {
  description: string;
  spHttpClient: SPHttpClient;
  siteUrl: string;
}

src/webparts/detailsListSample/DetailsListSampleWebPart.tsで、値をセットします。

public render(): void {
  const element: React.ReactElement<IDetailsListSampleProps> = React.createElement(
    DetailsListSample,
    {
      description: this.properties.description,
      spHttpClient: this.context.spHttpClient,
      siteUrl: this.context.pageContext.web.absoluteUrl,
    }
  );

  ReactDom.render(element, this.domElement);
}

src/webparts/detailsListSample/components/DetailsListSample.tsxを開いて、ヘルパークラスをインポートします。

import {
  SPHttpClient,
  SPHttpClientResponse
} from '@microsoft/sp-http';

では、Propsに持たせたspHttpClientを使って、SharePointからリストを取得するプライベートメソッドを追加します。

private _getListData(): Promise<IDetailsListItems> {
    return this.props.spHttpClient.get(this.props.siteUrl + `/_api/web/lists/getbytitle('リスト名')/items?$top=10`, SPHttpClient.configurations.v1)
      .then((response: SPHttpClientResponse) => {
        return response.json();
      });
  }

最後に、モックストアとSharePointを切り替えてデータを取得するためのプライベートメソッドを追加して、これを初期化のタイミングあたりでキックしてあげます。

private _getListItemAsync(): void {
  // Local environment
  if (Environment.type === EnvironmentType.Local) {
    this._getMockListData().then((response) => {
      this._allItems = response.value;
      this.setState({
        items: this._allItems,
      });
    });
  }
  else if (Environment.type == EnvironmentType.SharePoint ||
            Environment.type == EnvironmentType.ClassicSharePoint) {
    this._getListData()
      .then((response) => {
        this._allItems = response.value;
        this.setState({
          items: this._allItems,
        });
      });
  }
}

ローカルでプレビューすると、こんな感じになります。モックストアでセットしたデータがちゃんと表示されています。
mock-store-1.png

5. SharePointにWebパーツをデプロイする

次のコマンドでプロジェクトをビルドして、ソリューションをパッケージ化します。

gulp bundle --ship
gulp package-solution --ship

sharepoint/solution/detailslist-webpart.sppkgというファイルが生成されると思います。あとは、これをサイトにアップロードして展開すると、実際にWebパーツとして利用できるようになります。

ちょっと今回は適当なサイトがなかったので、キャプチャなどは割愛します。実際に動かしてみた感じでは、とてもサクサク動くのでびっくりしました。

おわりに

ReactベースでSharePointのWebパーツを作成しました。
これを応用すると、結構いろんなパーツが作れるので、めちゃくちゃ活用できそうです。何でもっと早くやらなかったんだろうと思うくらい、控えめに言って最強なソリューションでした。

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

[React]React コンポーネントでJSXが複数行になる場合になぜ()で囲むのか?

はじめに

今更ですが、みなさんReactコンポーネントを作る際に「なんで、JSXが複数行になる場合にのみ()で囲まないといけないのか?:thinking:」と疑問に思ったことはないでしょうか?

僕は夜も眠れないくらい気になったので、ちょっと調べてみました。

↓こんな場合の()です!

hello.js
import React from 'react';
const element = (
  <h1>
    Hello, {formatName(user)}!
  </h1>
);

結論

最初に結論からいうと、ReactでJSXが複数行になる場合には()で囲んだ方が良いです!

主な理由

  • 可読性向上のため
  • フォーマットを揃えるため

これだけだとまだ眠れないと思うので、調査した内容をまとめます。

まず、あの()は何であるか

グループ化演算子の()です。

みなさんもよく(1 + (2 * 3))などで利用する評価の優先順位を制御する演算子ですね!
ちょっと形式が変わるだけで人間すぐに混乱してしまいます。

この()で囲うことで、複数行のJSXも1つの式(Expression)として解釈されるようにしているんですね!

参考: MDN グループ化演算子
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Grouping

React公式ドキュメントの見解

読みやすさのため JSX を複数行に分けています。必須ではありませんが、複数行に分割する場合には、自動セミコロン挿入の落とし穴にはまらないように括弧で囲むことをおすすめします

可読性の向上と自動で文末にコロンが挿入されるのを防ぐために括弧で囲むのをオススメしてます。

参考:JSX の導入
https://ja.reactjs.org/docs/introducing-jsx.html

実際に()で囲わないとどうなるのか試してみる

では、実際に()で囲わないとどうなるのか試してみましょう。
今回は、下記のReact公式からリンクされているCodepenのコードを利用して色々試してみました。

参考:CodePen
https://ja.reactjs.org/redirect-to-codepen/components-and-props/rendering-a-component

例1: JSXを変数宣言時に代入

hello.js
var hello = 
<div>
  <h1>Hello</h1>
</div>;

const element = <Hello name="Sara" />;
ReactDOM.render(element, document.getElementById('root'));

=> 問題なく動作する。

例2: JSXをアロー関数でリターン

hello.js
const Hello = (props) => 
  <div>
    <h1> Hello, {props.name} </h1>
  </div>

const element = <Hello name="Sara" />;
ReactDOM.render(element, document.getElementById('root'));

=> 問題なく動作する。

例3(OKパターン): JSXをfunction内でリターン

hello.js
function Hello(props) {
  return  <div>
    <h1> Hello, {props.name} </h1>
  </div>
}

const element = <Hello name="Sara" />;
ReactDOM.render(element, document.getElementById('root'));

=> 問題なく動作する。

ただし、下記のようにreturnで改行された場合は当然ですが、returnで関数が終了してしまうので動作しません。

例3(NGパターン): JSXをfunction内でリターン

hello.js
function Hello(props) {
  return
  <div>  
    <h1> Hello, {props.name} </h1>
  </div>
}

const element = <Hello name="Sara" />;
ReactDOM.render(element, document.getElementById('root'));

このようにみていくとreturn文が登場するような

スタイルガイドではどうなっているか?

JavaScript Standard Styleでの推奨の書き方

JavaScript Standard Style で推奨の記載は下記です。
推奨以外の書き方以外はerrorになるようにeslintrc.jsonで定義されています。

eslintrc.json
"react/jsx-wrap-multilines": ["error", {
      "declaration": "parens-new-line",
      "assignment": "parens-new-line",
      "return": "parens-new-line",
      "arrow": "parens-new-line",
      "condition": "parens-new-line",
      "logical": "ignore",
      "prop": "ignore"
    }]

変数宣言時に代入

hello.js
var hello = (
  <div>
    <p>Hello</p>
  </div>
);

JSXをアロー関数でリターン

hello.js
var hello = () => (
  <div>
    <p>World</p>
  </div>
);

JSXをfunction内でリターン

hello.js
function hello() {
  return (
    <div>
      <p>Hello</p>
    </div>
  );
}

参考:
JavaScript Standard Styleでの方針
- https://github.com/standard/standard/issues/710
- https://github.com/standard/standard/commit/ccaf4390d9ae0829fdd31b2d69df143e9138e77d

EsLint React Pluginでの推奨の書き方

EsLint React PluginでもJSXが複数行になる場合には()で囲もうという方針ですね。
ただEsLint React Pluginではデフォルトでの設定がparensとなっている点が異なります。(JavaScript Standard Style では parens-new-lineとなっています。)

Prevent missing parentheses around multiline JSX (react/jsx-wrap-multilines)
Wrapping multiline JSX in parentheses can improve readability and/or convenience.

参考:jsx-wrap-multilines.md
https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-wrap-multilines.md

デフォルト設定

default
{
  "declaration": "parens",
  "assignment": "parens",
  "return": "parens",
  "arrow": "parens",
  "condition": "ignore",
  "logical": "ignore",
  "prop": "ignore"
}

parensparens-new-lineの違い

どちらも()で囲おうというのは同じなのですが、parens-new-lineの方が少し厳しいです。

どちらもOK

hello.js
var hello = (
  <div>
    <p>Hello</p>
  </div>
);

どちらもNG

hello.js
var hello = <div>
  <p>Hello</p>
</div>;

parensではOKだが、parens-new-lineでNG

hello.js
var hello = (<div>
  <p>Hello</p>
</div>);

最後に

ReactでJSXが複数行になる場合には()をつけましょう!
夜、()に悩まずに眠れるようになった人が少しでも増えたなら、幸いです。

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

React Hook Formでファイルアップロードを実装する

概要

React Hook Formというライブラリを使って、Reactでアンケートフォームを実装しました。
その中で画像アップロードを実装した際に、様々な工夫を行ったのでその記録を残します。

イメージ

画面収録 2020-09-30 23.40.43.mov.gif

要求仕様

  1. アップロードできる画像サイズの合計は10MB以内
  2. アップロードする画像のプレビューが必要
  3. アップロードできる画像(=プレビューの画像)の枚数は3枚
  4. 同じ画像はアップロードしないように弾く
  5. 画像のみアップロードする(jpeg, png, bmp, gif, svgのみ)
  6. それぞれの条件に反した場合には、エラーを表示する

React Hook Formを活用した背景

  1. 画像アップロード以外に、入力が必要な項目が存在したため
  2. ReactにおいてFormの実装がやりやすく、パフォーマンスも高いため

手順

  1. 画像アップロード部分のコンポーネント(PhotosUpload.tsx)の作成
  2. フォームを表示するページ(Questionnaire.tsx)の作成

工夫点

  1. アップロードできる画像サイズの合計は10MB以内
    →画像を圧縮することで、サイズを気にすることなくアップロードできるようにしました
     1枚あたり、3MBに圧縮する処理をbrowser-image-compressionを用いて行いました。

  2. アップロードする画像のプレビューが必要
    →画像アップロードのコンポーネントで、選択された画像をstateの配列で管理し、その配列によってDOMを出し分けました

  3. アップロードできる画像(=プレビューの画像)の枚数は3枚
    →上記stateの配列のlengthを3以下に制限することで、アップロードできる(プレビューできる)画像を3枚に制限しました

  4. 同じ画像はアップロードしないように弾く
    →同じサイズの画像は配列に追加できないというロジックで実装しました
     最初は画像の名前で弾く実装を行っていたのですが、safariで予想通りの挙動を示さなかったため変更しました。
     safariではheifの画像を選択した場合、jpegに変換する処理が走るようで、その際に画像名が自動的に付けられてしまうためであることが分かりました。
    より厳密に行うには、lastModifiedなど追加の情報を使うか、exifを読み込んで別の情報を取得することで、固有性を正確に担保できます。

  5. 画像のみアップロードする(jpeg, png, bmp, gif, svgのみ)
    →多くのブラウザではinputタグのacceptプロパティで"image/*"を指定すればいいですが、より厳密に行うために上記の配列への追加の際にfileのtypeプロパティで対象外のファイルを弾きました
     多くのスマートフォンのブラウザでは、rawやheifなどが"image/*"の対象となります。ただ一方で、imgタグではこれらの画像を表示することができません。そのため、今回これらの画像は対象外とし、配列の追加の際に弾く実装を行いました。
     ただ前述の通り、safariではheifはjpegに変換されるため利用できます。(Androidではheifは選択できるものの、変換されないため、今回の場合は対象外になります。)
    ちなみにPCのブラウザではrawやheifなどは選択の際に無効になります。

  6. それぞれの条件に反した場合には、エラーを表示する
     上記、①3枚以内か、②同じ画像ではないか、③対象のファイルタイプかの3点それぞれについて、エラーを保持するstateを準備し、それぞれのエラーが発生した際にtrueとすることで、それをきっかけにエラーのDOMを出し分けました。

実装

PhotosUpload.tsx
import React, { useState } from "react";
import * as styles from "./style.module.sass";
import PhotoSample from "../PhotoSample";

interface PhotosUploadProps {
  name: string;
  componentRef?: (instance: HTMLInputElement | null) => void;
  photos: File[];
  setPhotos: (files: File[]) => void;
}

const PhotosUpload: React.FC<PhotosUploadProps> = ({
  name,
  componentRef,
  photos,
  setPhotos,
}: PhotosUploadProps): React.ReactElement => {
  const [isSameError, setIsSameError] = useState(false);
  const [isNumberError, setIsNumberError] = useState(false);
  const [isFileTypeError, setIsFileTypeError] = useState(false);

  const resetErrors = () => {
    setIsSameError(false);
    setIsNumberError(false);
    setIsFileTypeError(false);
  };

  const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files === null || event.target.files.length === 0) {
      return;
    }
    const files = Object.values(event.target.files).concat();
    // 初期化することで同じファイルを連続で選択してもonChagngeが発動するように設定し、画像をキャンセルしてすぐに同じ画像を選ぶ動作に対応
    event.target.value = "";
    resetErrors();

    const pickedPhotos = files.filter((file) => {
      if (
        ![
          "image/gif",
          "image/jpeg",
          "image/png",
          "image/bmp",
          "image/svg+xml",
        ].includes(file.type)
      ) {
        setIsFileTypeError(true);
        return false;
      }

      const existsSameSize = photos.some((photo) => photo.size === file.size);
      if (existsSameSize) {
        setIsSameError(true);
        return false;
      }

      return true;
    });

    if (pickedPhotos.length === 0) {
      return;
    }
    const concatPhotos = photos.concat(pickedPhotos);
    if (concatPhotos.length >= 4) {
      setIsNumberError(true);
    }
    setPhotos(concatPhotos.slice(0, 3));
  };

  const handleCancel = (photoIndex: number) => {
    if (confirm("選択した画像を消してよろしいですか?")) {
      resetErrors();
      const modifyPhotos = photos.concat();
      modifyPhotos.splice(photoIndex, 1);
      setPhotos(modifyPhotos);
    }
  };

  return (
    <>
      <div className={styles.topContainer}>
        {[...Array(3)].map((_: number, index: number) =>
          index < photos.length ? (
            <button
              type="button"
              className={styles.imageContainer}
              key={index}
              onClick={() => handleCancel(index)}
            >
              <img
                className={styles.image}
                src={URL.createObjectURL(photos[index])}
                alt={`あなたの写真 ${index + 1}`}
              />
            </button>
          ) : (
            <label htmlFor={name} key={index}>
              <PhotoSample number={index + 1} />
            </label>
          )
        )}
      </div>
      {isSameError && (
        <p>※既に選択された画像と同じものは表示されません</p>
      )}
      {isNumberError && (
        <p>※3枚を超えて選択された画像は表示されません</p>
      )}
      {isFileTypeError && (
        <p>※jpeg, png, bmp, gif, svg以外のファイル形式は表示されません</p>
      )}

      <div className={styles.bottomContainer}>
        <div>
          <p className={styles.note}>※最大3枚まで</p>
        </div>
        <label className={styles.label} htmlFor={name}>
          <div className={styles.plus}></div>
          写真を追加
          <input
            className={styles.input}
            type="file"
            name={name}
            id={name}
            ref={componentRef}
            accept="image/*"
            onChange={handleFile}
            multiple
          />
        </label>
      </div>
    </>
  );
};

export default PhotosUpload;
Questionnaire.tsx
import { useForm } from "react-hook-form";
import { navigate } from "gatsby";
import axios from "axios";
import imageCompression from "browser-image-compression";
import PhotosUpload from "../PhotosUpload";

type Inputs = {
  email: string;
  phone: string;
};

const Questionnaire: React.FC= () => {
  const { register, errors, handleSubmit } = useForm<Inputs>({
    mode: "onBlur",
  });
  const [photos, setPhotos] = useState<File[]>([]);

  const onSubmit = async (data: Inputs): Promise<void> => {
    const { email, phone } = data;
    if (
      email === "" &&
      phone === "" &&
      photos.length === 0
    ) {
      // アンケートフォームが空の場合はPOSTしない
      return;
    }

    // 画像を送信できるようにFormDataに変換する
    const formData = new FormData();
    formData.append("email", email);
    formData.append("phone", phone);

    const compressOptions = {
      // 3MB以下に圧縮する
      maxSizeMB: 3,
    };
    const compressedPhotoData = await Promise.all(
      photos.map(async (photo) => {
        return {
          blob: await imageCompression(photo, compressOptions),
          name: photo.name,
        };
      })
    );
    compressedPhotoData.forEach((photoData) => {
      formData.append("photo", photoData.blob, photoData.name);
    });

    axios({
      url: "/api/register",
      method: "post",
      data: formData,
      headers: {
        "content-type": "multipart/form-data",
      },
    })
      .then(() =>  navigate("/complete");)
      .catch((error) => {
        alert("エラーが発生しました。");
      });
  };

  return (
      <form onSubmit={handleSubmit(onSubmit)}>
          <div className={styles.dataContainer}>
            <input
              name="email"
              ref={register({required : true })}
              error={errors.email !== undefined}
            />
            <input
              name="phone"
              ref={register({required : true })}
              error={errors.phone !== undefined}
            />
          </div>
          <div className={styles.photoUpload}>
            <PhotosUpload name="photos" photos={photos} setPhotos={setPhotos} />
          </div>
          <div className={styles.button}>
            <button disabled={ />
          </div>
      </form>
  );
};
export default Questionnaire;

参考

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

React Hook Formで画像アップロードを実装する

概要

React Hook Formというライブラリを使って、Reactでアンケートフォームを実装しました。
その中で画像アップロードを実装した際に、様々な工夫を行ったのでその記録を残します。

イメージ

画面収録 2020-09-30 23.40.43.mov.gif

要求仕様

  1. アップロードできる画像サイズの合計は10MB以内
  2. アップロードする画像のプレビューが必要
  3. アップロードできる画像(=プレビューの画像)の枚数は3枚
  4. 同じ画像はアップロードしないように弾く
  5. 画像のみアップロードする(jpeg, png, bmp, gif, svgのみ)
  6. それぞれの条件に反した場合には、エラーを表示する

React Hook Formを活用した背景

  1. 画像アップロード以外に、入力が必要な項目が存在したため
  2. ReactにおいてFormの実装がやりやすく、パフォーマンスも高いため

手順

  1. 画像アップロード部分のコンポーネント(PhotosUpload.tsx)の作成
  2. フォームを表示するページ(Questionnaire.tsx)の作成

工夫点

  1. アップロードできる画像サイズの合計は10MB以内
    →画像を圧縮することで、サイズを気にすることなくアップロードできるようにしました
     1枚あたり、3MBに圧縮する処理をbrowser-image-compressionを用いて行いました。

  2. アップロードする画像のプレビューが必要
    →画像アップロードのコンポーネントで、選択された画像をstateの配列で管理し、その配列によってDOMを出し分けました

  3. アップロードできる画像(=プレビューの画像)の枚数は3枚
    →上記stateの配列のlengthを3以下に制限することで、アップロードできる(プレビューできる)画像を3枚に制限しました

  4. 同じ画像はアップロードしないように弾く
    →同じサイズの画像は配列に追加できないというロジックで実装しました
     最初は画像の名前で弾く実装を行っていたのですが、safariで予想通りの挙動を示さなかったため変更しました。
     safariではheifの画像を選択した場合、jpegに変換する処理が走るようで、その際に画像名が自動的に付けられてしまうためであることが分かりました。
    より厳密に行うには、lastModifiedなど追加の情報を使うか、exifを読み込んで別の情報を取得することで、固有性を正確に担保できます。

  5. 画像のみアップロードする(jpeg, png, bmp, gif, svgのみ)
    →多くのブラウザではinputタグのacceptプロパティで"image/*"を指定すればいいですが、より厳密に行うために上記の配列への追加の際にfileのtypeプロパティで対象外のファイルを弾きました
     多くのスマートフォンのブラウザでは、rawやheifなどが"image/*"の対象となります。ただ一方で、imgタグではこれらの画像を表示することができません。そのため、今回これらの画像は対象外とし、配列の追加の際に弾く実装を行いました。
     ただ前述の通り、safariではheifはjpegに変換されるため利用できます。(Androidではheifは選択できるものの、変換されないため、今回の場合は対象外になります。)
    ちなみにPCのブラウザではrawやheifなどは選択の際に無効になります。

  6. それぞれの条件に反した場合には、エラーを表示する
     上記、①3枚以内か、②同じ画像ではないか、③対象のファイルタイプかの3点それぞれについて、エラーを保持するstateを準備し、それぞれのエラーが発生した際にtrueとすることで、それをきっかけにエラーのDOMを出し分けました。

実装

PhotosUpload.tsx
import React, { useState } from "react";
import * as styles from "./style.module.sass";
import PhotoSample from "../PhotoSample";

interface PhotosUploadProps {
  name: string;
  componentRef?: (instance: HTMLInputElement | null) => void;
  photos: File[];
  setPhotos: (files: File[]) => void;
}

const PhotosUpload: React.FC<PhotosUploadProps> = ({
  name,
  componentRef,
  photos,
  setPhotos,
}: PhotosUploadProps): React.ReactElement => {
  const [isSameError, setIsSameError] = useState(false);
  const [isNumberError, setIsNumberError] = useState(false);
  const [isFileTypeError, setIsFileTypeError] = useState(false);

  const resetErrors = () => {
    setIsSameError(false);
    setIsNumberError(false);
    setIsFileTypeError(false);
  };

  const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files === null || event.target.files.length === 0) {
      return;
    }
    const files = Object.values(event.target.files).concat();
    // 初期化することで同じファイルを連続で選択してもonChagngeが発動するように設定し、画像をキャンセルしてすぐに同じ画像を選ぶ動作に対応
    event.target.value = "";
    resetErrors();

    const pickedPhotos = files.filter((file) => {
      if (
        ![
          "image/gif",
          "image/jpeg",
          "image/png",
          "image/bmp",
          "image/svg+xml",
        ].includes(file.type)
      ) {
        setIsFileTypeError(true);
        return false;
      }

      const existsSameSize = photos.some((photo) => photo.size === file.size);
      if (existsSameSize) {
        setIsSameError(true);
        return false;
      }

      return true;
    });

    if (pickedPhotos.length === 0) {
      return;
    }
    const concatPhotos = photos.concat(pickedPhotos);
    if (concatPhotos.length >= 4) {
      setIsNumberError(true);
    }
    setPhotos(concatPhotos.slice(0, 3));
  };

  const handleCancel = (photoIndex: number) => {
    if (confirm("選択した画像を消してよろしいですか?")) {
      resetErrors();
      const modifyPhotos = photos.concat();
      modifyPhotos.splice(photoIndex, 1);
      setPhotos(modifyPhotos);
    }
  };

  return (
    <>
      <div className={styles.topContainer}>
        {[...Array(3)].map((_: number, index: number) =>
          index < photos.length ? (
            <button
              type="button"
              className={styles.imageContainer}
              key={index}
              onClick={() => handleCancel(index)}
            >
              <img
                className={styles.image}
                src={URL.createObjectURL(photos[index])}
                alt={`あなたの写真 ${index + 1}`}
              />
            </button>
          ) : (
            <label htmlFor={name} key={index}>
              <PhotoSample number={index + 1} />
            </label>
          )
        )}
      </div>
      {isSameError && (
        <p>※既に選択された画像と同じものは表示されません</p>
      )}
      {isNumberError && (
        <p>※3枚を超えて選択された画像は表示されません</p>
      )}
      {isFileTypeError && (
        <p>※jpeg, png, bmp, gif, svg以外のファイル形式は表示されません</p>
      )}

      <div className={styles.bottomContainer}>
        <div>
          <p className={styles.note}>※最大3枚まで</p>
        </div>
        <label className={styles.label} htmlFor={name}>
          <div className={styles.plus}></div>
          写真を追加
          <input
            className={styles.input}
            type="file"
            name={name}
            id={name}
            ref={componentRef}
            accept="image/*"
            onChange={handleFile}
            multiple
          />
        </label>
      </div>
    </>
  );
};

export default PhotosUpload;
Questionnaire.tsx
import { useForm } from "react-hook-form";
import { navigate } from "gatsby";
import axios from "axios";
import imageCompression from "browser-image-compression";
import PhotosUpload from "../PhotosUpload";

type Inputs = {
  email: string;
  phone: string;
};

const Questionnaire: React.FC= () => {
  const { register, errors, handleSubmit } = useForm<Inputs>({
    mode: "onBlur",
  });
  const [photos, setPhotos] = useState<File[]>([]);

  const onSubmit = async (data: Inputs): Promise<void> => {
    const { email, phone } = data;
    if (
      email === "" &&
      phone === "" &&
      photos.length === 0
    ) {
      // アンケートフォームが空の場合はPOSTしない
      return;
    }

    // 画像を送信できるようにFormDataに変換する
    const formData = new FormData();
    formData.append("email", email);
    formData.append("phone", phone);

    const compressOptions = {
      // 3MB以下に圧縮する
      maxSizeMB: 3,
    };
    const compressedPhotoData = await Promise.all(
      photos.map(async (photo) => {
        return {
          blob: await imageCompression(photo, compressOptions),
          name: photo.name,
        };
      })
    );
    compressedPhotoData.forEach((photoData) => {
      formData.append("photo", photoData.blob, photoData.name);
    });

    axios({
      url: "/api/register",
      method: "post",
      data: formData,
      headers: {
        "content-type": "multipart/form-data",
      },
    })
      .then(() =>  navigate("/complete");)
      .catch((error) => {
        alert("エラーが発生しました。");
      });
  };

  return (
      <form onSubmit={handleSubmit(onSubmit)}>
          <div className={styles.dataContainer}>
            <input
              name="email"
              ref={register({required : true })}
              error={errors.email !== undefined}
            />
            <input
              name="phone"
              ref={register({required : true })}
              error={errors.phone !== undefined}
            />
          </div>
          <div className={styles.photoUpload}>
            <PhotosUpload name="photos" photos={photos} setPhotos={setPhotos} />
          </div>
          <div className={styles.button}>
            <button disabled={ />
          </div>
      </form>
  );
};
export default Questionnaire;

参考

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

Hooks APIを使ったFunctional Component間のデータやイベントのやり取り

はじめに

React初級者ぐらいの私が、Functional ComponentでHooks APIを使って以下をどう書くか知らべた結果を書きます。

  • 親と孫でstateを共通管理する
  • 親から孫の関数を呼び出す
  • 子は、上記に関することを何も書かない
  • React-Redux ってどうなる?

以下のHooks APIを使いました

  • useState
  • useContext
  • useReducer

成果物

公式にもよく出てくる、Counterを+や-ボタンで増減させるアプリです。

sample.gif

ここでコード参照できます。

背景

個々のhooksの機能や1ファイルでの書き方しか見つからず、親子孫がファイル分割された状態でどう書くの?というのが分からなかったので、調べながら実際に実装してみました。

ファイル構成

public/components/以下がメインで、このような構成になっています。

parent.js・・・親component
child.js・・・子component
grand_child.js・・・孫component
(一番知りたかったのは上記3つをどう書くか)
context.js・・・コンテキスト処理の共通化
reducer.js・・・reducer処理

この構成で、親と孫間で、共通のstate参照や、関数を呼び出したいときに
parent.js, grand_child.js に何を書くのか、というのが知りたかったポイントです。

コードの解説

親Component

まずは、関数(コールバック)やstateを共有したいcomponent達の親となるcomponent parent.jsに、useReducerを書きます。

parent.js
export default function ParentComponent() {
  const [state, dispatch] = useReducer(reducer, initialState)

reducerinitialStaterecuder.jsに切り出しました。

statedispatchを親component配下のcomponent達で参照できる用contextを用意します。

parent.js
import {ContextAppDispatch, ContextState} from "./context.js"

context.js は、孫でも参照するので外部化しました。

context.js
import React from "react"

export const ContextAppDispatch = React.createContext("AppDispatch");
export const ContextState = React.createContext("state");

定義したものを、配下のcomponentが受け取れるようにします。

parent.js
  return (
    <ContextAppDispatch.Provider value={dispatch}>
      <ContextState.Provider value={state}>
        <h1>ParentComponent</h1>
        <p>Count: {state.count}</p>
                (中略)
        <ChiledComponent />
      </ContextState.Provider>
    </ContextAppDispatch.Provider>

孫Componentで呼び出し

useContextで呼び出します。

grand_child.js
import {useContext} from "react"
import {ContextAppDispatch,ContextState} from "./context.js"

export default function GrandChildComponent() {
  const dispatch = useContext(ContextAppDispatch);
  const state = useContext(ContextState);
  return (
    <>
      <h1>GrandChildComponent</h1>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

こうすることで、以下ができました。

  • 親で定義したdispatchを孫も呼べる
  • 親で定義したstateを孫も参照できる

このとき、子は何も上記に関与していません。

child.js
import GrandChiledComponent from './grand_child.js'
export default function ChildComponent() {
  return (
    <>
      <h1>ChildComponent</h1>
      <GrandChiledComponent />
    </>
  )
}

複数のcomponent間でのデータやコールバックの受け渡し方法

今回の実装方針として、公式に書かれている方法にできるだけ沿うようにしました。

公式に以下の記載があります.

大きなコンポーネントツリーにおいて我々がお勧めする代替手段は、useReducer で dispatch 関数を作って、それをコンテクスト経由で下の階層に渡す
(中略)
アプリケーションの state については、props として渡していくか(より明示的)、あるいはコンテクスト経由で渡すか(深い更新ではより便利)を選ぶ余地が依然あります。

ということで、コールバックもstateも受け渡しはcontextでまとめちゃいました。

複数のcontextを使う

そうすると、

  • dispatch用context
  • state用context

の2つが必要になります。一つのcontextでも実装可能ですが、公式

コンテクストの再レンダーを高速に保つために、React は各コンテクストのコンシューマをツリー内の別々のノードにする必要があります。

とあるので、分けます。

それが以下の部分

context.js
export const ContextAppDispatch = React.createContext("AppDispatch");
export const ContextState = React.createContext("state");
parent.js
    <ContextAppDispatch.Provider value={dispatch}>
      <ContextState.Provider value={state}>
      (中略)
      </ContextState.Provider>
    </ContextAppDispatch.Provider>

できたことまとめ

  • 親、子、孫をFunctional Componentで実装できた
  • 子に何も書かず、親と孫で共通のstateやactionを呼び出せた

疑問・課題

やり残したことや、新たに出てきた疑問など。React詳しい方のツッコミもらえると幸いです。

  • contextは、 context.js を外部化して共通利用という方法で合ってる?
  • 今回は親でuseReducer使って孫にdispatchを渡しているが、孫で定義されたactionを親で呼びたい場合はどうするか知りたい
    • useRefとか使えばいけそう?
    • そもそもそれが必要な場面てある?
      • 無いなら考える必要なし
  • reducer.js とか context.js の保存場所は componentsディレクトリとは別のディレクトリが良さそう
  • action部分 {type: 'increment'} も外部化したほうが良さそう
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React.js】Hooks APIを使ったFunctional Component間のデータやイベントのやり取り

はじめに

React初級者ぐらいの私が、Functional ComponentでHooks APIを使って以下をどう書くか知らべた結果を書きます。

  • 親と孫でstateを共通管理する
  • 親から孫の関数を呼び出す
  • 子は、上記に関することを何も書かない
  • React-Redux ってどうなる?

以下のHooks APIを使うことで実現できました。

  • useState
  • useContext
  • useReducer

成果物

公式にもよく出てくる、Counterを+や-ボタンで増減させるアプリです。

アプリの動き
sample.gif

ここでコード参照できます。

背景

個々のhooksの機能や1ファイルでの書き方しか見つからず、親子孫がファイル分割された状態でどう書くの?というのが分からなかったので、調べながら実際に実装してみました。

解説

ファイル構成

public/components/以下がメインで、このような構成になっています。

parent.js・・・親component
child.js・・・子component
grand_child.js・・・孫component
(一番知りたかったのは上記3つをどう書くか)
context.js・・・コンテキスト処理の共通化
reducer.js・・・reducer処理

この構成で、親と孫間で、共通のstate参照や、関数を呼び出したいときに
parent.js, grand_child.js に何を書くのか、というのが知りたかったポイントです。

コードの解説

親Component

まずは、関数(コールバック)やstateを共有したいcomponent達の親となるcomponent parent.jsに、useReducerを書きます。

parent.js
export default function ParentComponent() {
  const [state, dispatch] = useReducer(reducer, initialState)

reducerinitialStatereducer.jsに切り出しました。

statedispatchを親component配下のcomponent達で参照できる用contextを用意します。

parent.js
import {ContextAppDispatch, ContextState} from "./context.js"

context.js は、孫でも参照するので外部化しました。

context.js
import React from "react"

export const ContextAppDispatch = React.createContext("AppDispatch");
export const ContextState = React.createContext("state");

定義したものを、配下のcomponentが受け取れるようにします。

parent.js
  return (
    <ContextAppDispatch.Provider value={dispatch}>
      <ContextState.Provider value={state}>
        <h1>ParentComponent</h1>
        <p>Count: {state.count}</p>
                (中略)
        <ChiledComponent />
      </ContextState.Provider>
    </ContextAppDispatch.Provider>

Contextが2重になっているのは後述します。

孫Componentで呼び出し

useContextで呼び出します。

grand_child.js
import {useContext} from "react"
import {ContextAppDispatch,ContextState} from "./context.js"

export default function GrandChildComponent() {
  const dispatch = useContext(ContextAppDispatch);
  const state = useContext(ContextState);
  return (
    <>
      <h1>GrandChildComponent</h1>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

こうすることで、以下ができました。

  • 親で定義したdispatchを孫も呼べる
  • 親で定義したstateを孫も参照できる

このとき、子は何も上記に関与していません。

child.js
import GrandChiledComponent from './grand_child.js'
export default function ChildComponent() {
  return (
    <>
      <h1>ChildComponent</h1>
      <GrandChiledComponent />
    </>
  )
}

複数のcomponent間でのデータやコールバックの受け渡し方法

今回の実装方針として、公式に書かれている方法にできるだけ沿うようにしました。

公式に以下の記載があります.

大きなコンポーネントツリーにおいて我々がお勧めする代替手段は、useReducer で dispatch 関数を作って、それをコンテクスト経由で下の階層に渡す
(中略)
アプリケーションの state については、props として渡していくか(より明示的)、あるいはコンテクスト経由で渡すか(深い更新ではより便利)を選ぶ余地が依然あります。

ということで、(今回は大きなコンポーネントツリーではないですが)コールバックもstateも受け渡しはcontextでまとめることで、コードはすっきりしたと思います。

複数のcontextを使う

そうすると、

  • dispatch用context
  • state用context

の2つが必要になります。1つのcontextでも実装可能ですが、公式には

コンテクストの再レンダーを高速に保つために、React は各コンテクストのコンシューマをツリー内の別々のノードにする必要があります。

とあるので、分けました。

それが以下の部分

context.js
export const ContextAppDispatch = React.createContext("AppDispatch");
export const ContextState = React.createContext("state");
parent.js
    <ContextAppDispatch.Provider value={dispatch}>
      <ContextState.Provider value={state}>
      (中略)
      </ContextState.Provider>
    </ContextAppDispatch.Provider>

できたことまとめ

  • 親、子、孫をFunctional Componentで実装できた
  • 子に何も書かず、親と孫で共通のstateやactionを呼び出せた

疑問・課題

やり残したことや、新たに出てきた疑問など。React詳しい方のツッコミもらえると幸いです。

  • contextは、 context.js を外部化して共通利用という方法で合ってる?
  • reducer.js とか context.js の保存場所は componentsディレクトリとは別のディレクトリが良さそう
  • action部分 {type: 'increment'} も外部化したほうが良さそう
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

TrelloみたいなカンバンUIを作りたいので、Javascriptのドラッグ&ドロップについて調べてみた

Trelloのようなタスク管理で、タスクをドラッグ&ドロップで移動する操作がありますよね。
今までドラッグ&ドロップの処理を書いたことがないので、調べてみました。

作ったサンプル

調べた結果、こんな感じのサンプルを実装してみました。
kanban_ui_sample.gif

APIについて調べる

以下、調べていく過程を記載します。
MDNによると、JavascriptのAPIがちゃんと用意されているようです。
https://developer.mozilla.org/ja/docs/Web/API/HTML_Drag_and_Drop_API

以下のAPIを使えば、考えているものが作れそうに思えました。
- ドラッグを開始した時はondragstart
- ドラッグしている項目が、ドロップ対象に入るとondragover
- ドロップした時はondrop
- ドラッグしている項目が、ドロップ対象から外れるとondragleave

ドラッグするデータは、ドラッグ開始時にdataTransferオブジェクトというのを使うらしい。
以下、MDNからサンプルコードを引用

function dragstart_handler(ev) {
  ev.dataTransfer.setData("text/plain", ev.target.innerText);
  ev.dataTransfer.setData("text/html", ev.target.outerHTML);
  ev.dataTransfer.setData("text/uri-list", ev.target.ownerDocument.location.href);
}

そして、ドロップする場所で、同じくdataTransferオブジェクトを受け取るらしい。
以下、MDNからサンプルコードを引用

function drop_handler(ev) {
 ev.preventDefault();
 var data = ev.dataTransfer.getData("text/plain");
 ev.target.appendChild(document.getElementById(data));
}

reactで実装

なんだ、意外とカンタンにできそうだと思ったので、reactでちょっとサンプルを実装してみます。
TaskListとTaskというコンポーネントを作って、登録したタスクが移動できるか試してみます。
ドラッグ&ドロップAPIの動作確認することが目的なので、つくりが雑なのは悪しからず。

タスク一覧を表示するTaskList

const TaskList = ({ status, title, tasks }) => {
  const { globalState, dispatch } = useContext(StateContext)

  const handleDragOver = (e) => {
    e.preventDefault()
    if (e.dataTransfer) {
      console.log('drop ok')
    }
  }

  const handleDrop = (e) => {
    e.preventDefault()
    const data = e.dataTransfer.getData('text/plain').split(',')
    dispatch({ type: 'MOVE_TASK', payload: { id: Number(data[0]), prevStatus: data[1], newStatus: status } })
  }

  const handleDragLeave = (e) => {
    e.preventDefault()
    console.log('dragleave')
  }

  return (
    <div className="box"
      id={status}
      onDrop={handleDrop}
      onDragOver={handleDragOver}
      onDragLeave={e => handleDragLeave(e)}>
      <div className="box-title">{title}</div>
      {status === 'beforeWork' && <button className="new-task">課題を作成</button>}

      {tasks && tasks.map((task, idx) => (
        <Task key={idx} {...task} />
      ))}
    </div>
  )
}

export default TaskList

個別のタスクを表示するTask

const Task = ({ id, name, status }) => {

  const handleDragStart = (e) => {
    e.dataTransfer.setData('text/plain', `${e.target.id.replace('task-', '')},${status}`)
    e.dataTransfer.effectAllowed = 'move'
  }

  return (
    <div className="task" draggable="true" id={`task-${id}`} onDragStart={e => handleDragStart(e)}>
      <div className="task-name">{name}</div>
    </div>
  )
}

export default Task

こんな感じで、ドラッグ&ドロップAPIの動作が確認できました。
前からドラッグ&ドロップの処理が気になってたので、今回調べたのはいい機会でした。
今回のサンプルコードはここに上げてあります。
https://github.com/koyoukai/kanban-ui-sample

ドラッグ&ドロップAPIを使うなら、こうした方がいい的な改善等ありましたら、
ぜひご指摘ください。

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

Gatsby + Contentful + Github pagesで綺麗なブログを作成してみる

目的

デザインテーマを適用したJAMstackなブログサイトをセットアップします。

用意するもの

  • Contentfulのアカウント
  • ブログの著者アイコンとして利用するアバター画像(顔写真など)
  • GitとNodeと、npmかyarnがつかえる環境

作業環境

項目 Version
Ubuntu 18.04
node v14.7.0
yarn 1.21.1

Gatsby+Themeのセットアップ

GatsbyはReact製の静的サイトジェネレータです。Webサーバで公開するためのhtmlファイルやcssファイルを作成してくれます。多くのテーマが公開されています。

デザインテーマを決める

次のサイトなどでGatsby用でContentfulに対応しているテーマを探します。

今回は多くのGithubスターを集めているgatsby-theme-novelaを利用します。
下のようなテーマです。

novela.png

こちらでLiveデモを確認できます。

テーマを取得

ここでgatsby-theme-novelaをcloneしたいところですがREADMEを読むとgatsby-startarのためのリポジトリは別に存在します。なので次のリポジトリをcloneしてください。

gatsby-starter-novela

git clone git@github.com:narative/gatsby-starter-novela.git <ローカルフォルダ名>

セットアップと確認用サーバ起動

READMEの通りにコマンドを実行します。

cd <ローカルフォルダ名>
yarn
yarn global add gatsby
yarn dev

次のURLで表示されれば成功です。

http://localhost:8000/

Contentful関連のセットアップ

記事データはContentfulというサービスに保存します。投稿や修正もContentfulの管理画面から実施します。

Spaceを作成

SpaceはContentfulにおけるプロジェクトのような概念です。Webの管理画面から作成してください。

無料プランでは1つしか作成できないのですが、アカウント登録時にサンプルデータを作成していると既に1つ作られていて追加できませんので、サンプル用のSpaceを削除する必要があります。

Contentfulにブログ用モデルを追加

READMEと順番が違いますが、先にContentfulへブログ用のモデルを追加します(モデルとはRDBにおけるTableのような概念です)。

モデルの構造はテーマによって違うので、他のテーマで作業する場合はREADMEを参照しながら適宜読み替えてください。

Contentful CLIのインストール

モデルのインポートをWebコンソールから出来ると良いのですが、方法が分からないのでCLIをインストールしてコマンドラインから実施します。

# yarnの場合
yarn global add contentful-cli

# npmの場合
npm -g contentful-cli

詳細は次サイトにあります

https://www.contentful.com/developers/docs/tutorials/cli/installation/

テーマ用のモデル定義ファイルをダウンロード

次のURLにあるcontentful-export.jsonをダウンロードします。

https://github.com/narative/gatsby-theme-novela/tree/master/%40narative/gatsby-theme-novela/contentful

コピー&ペーストでも良いですが、直接ダウンロードする場合は次のようにします。

wget https://raw.githubusercontent.com/narative/gatsby-theme-novela/master/%40narative/gatsby-theme-novela/contentful/contentful-export.json

モデルをインポート

# Contentful cliの利用を開始する
# 対話式にキーをコピペしてログイン処理を実施
# (nodeのバージョン管理環境によっては、「npx contentful login」とする必要があるかもしれません)
contentful login

# 利用するspaceを選択
# 対話式にスペースを選択
contentful space use

# モデルのインポートを実施
contentful space import --content-file contentful-export.json

The import was successful. と表示されれば成功です。

上記インポートでは、著者情報と記事情報の二つのモデルが作成されます。

  • Auther(著者情報)
  • Post(記事情報)

(テーマ固有のトラブル対応)

残念なことに、モデル作成用として配布されているjsonファイルの内容と、実際にテーマが使用しているモデルの名前が一致しません。バグなのか、更新のミスマッチなのかわかりませんが、次の作業が必要です(2020年7月30日現在)。

ややこしい話ですが、このトラブルはテーマのバージョンが0.16(付近)以降に発生しているようです。依存関係により0.13など古いバージョンがインストールされる場合があり、この場合は次のモデル名の修正は不要です。

モデル名の修正は、ContentfulのWebコンソールから、Postのモデルを「Duplicate」し、Articleとして複製して保存します。

これで、Contentfulには次の3つのモデルが作成された状態になります。

  • Auther(著者情報)
  • Post(記事情報)
  • Article(記事情報)

古いPostモデルは削除しないでください。エラーが発生します。

Contentfulへcontentを追加

AutherとArticleにcontent(RDBで言う所のレコード)を追加します。

Web画面で、Content -> Add entry の順にクリックして各モデルに対するcontentを作成します。今回はサンプルとして次の情報を入れました。

Auther(著者情報)のサンプルcontent

auther.png

*画像などのメディアが一つも登録されていないとGatsbyでデータを取得する際にエラーが発生する場合があるようです。著者アイコンはとりあいず入れておいた方がよさそうです。

Article(記事情報)のサンプルcontent

(画像ではモデル名がPostとなっていますが、Articleと読み替えてください)

post.png

どれも一般的な項目ですが、いわゆるアイキャッチ画像のフィールド名がHeroなのは最初戸惑いました。「Excerpt」はトップの一覧で使用される要約文で、Max200文字とありますが一定の文字数以降は「...」と省略されてしまうので注意が必要です。

Contentfulとの接続設定

READMEに従って.envファイルとgatsby-config.jsを編集します。初めに、必要になるキー情報を取得します。

Contentfulのキー情報を取得

Contentfulの管理画面へログインし、

「settings」ー>「API Keys」と選択してKey情報の管理ページを開き、右上の「Add API Key」を押して今回利用するための鍵を作成します。

作成した鍵情報のうち、次の2つを使用します。

  • Space ID
  • Content Delivery API - access token

.env を編集

.envファイルを作成もしくは開き、次の2行を追加します。

CONTENTFUL_SPACE_ID= (Your Contentful Space ID)
CONTENTFUL_ACCESS_TOKEN= (Your Content Delivery API - access token)

各変数の右辺に、先ほど作成した2つの鍵情報をコピーして保存します。

必要なモジュールを追加

次のコマンドで追加します。

yarn add gatsby-source-contentful dotenv

gatsby-config.js を編集

READMEのサンプルを元に、既存のgatsby-config.jsを編集します。

READMEにあるサンプルは次のとおりですが、これをそのままコピペしてはいけません

require('dotenv').config();

plugins: [
  {
    resolve: 'gatsby-source-contentful',
    options: {
      spaceId: process.env.CONTENTFUL_SPACE_ID,
      accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
    },
  },
  {
    resolve: '@narative/gatsby-theme-novela',
    options: {
      sources: {
        contentful: true,
      },
    },
  },
];

既存のgatsby-config.jsの構造を考えながら、次のように書き換えます。

+ require('dotenv').config();

module.exports = {
  siteMetadata: {
    title: `Novela by Narative`,
    name: `Narative`,
    siteUrl: `https://novela.narative.co`,
    description: `This is my description that will be used in the meta tags and important for search results`,
    hero: {
      heading: `Welcome to Novela, the simplest way to start publishing with Gatsby.`,
      maxWidth: 652,
    },
    social: [
      {
        name: `twitter`,
        url: `https://twitter.com/narative`,
      },
      {
        name: `github`,
        url: `https://github.com/narative`,
      },
      {
        name: `instagram`,
        url: `https://instagram.com/narative.co`,
      },
      {
        name: `linkedin`,
        url: `https://www.linkedin.com/company/narative/`,
      },
      {
        name: `dribbble`,
        url: `https://dribbble.com/narativestudio`,
      },
    ],
  },
  plugins: [
    {
      resolve: "@narative/gatsby-theme-novela",
      options: {
        contentPosts: "content/posts",
        contentAuthors: "content/authors",
        basePath: "/",
        authorsPage: true,
        sources: {
-          local: true,
+          local: false,
+          contentful: true,
        },
      },
    },
    {
      resolve: `gatsby-plugin-manifest`,
      options: {
        name: `Novela by Narative`,
        short_name: `Novela`,
        start_url: `/`,
        background_color: `#fff`,
        theme_color: `#fff`,
        display: `standalone`,
        icon: `src/assets/favicon.png`,
      },
    },
-    {
-      resolve: `gatsby-plugin-netlify-cms`,
-      options: {
-      },
-    },
+    {
+      resolve: 'gatsby-source-contentful',
+      options: {
+        spaceId: process.env.CONTENTFUL_SPACE_ID,
+        accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
+      },
+    },
  ],
};

プラグインの部分の「+ local: false」はReadmeに無い項目ですが、これをしないとlocalの記事情報の読み込みがオフになりませんでした。バグかもしれません(2020年7月30日現在)。

ローカルで改めて起動

yarn dev

次のURLで、先ほどContentfulに登録したAuther情報とArticleの内容が表示されれば成功です。

http://localhost:8000

local.png

Github pagesへデプロイ

ひとまずGatsby+Contentfulでの環境構築が完了しました。gatsby buildと打てば./publicの下に静的ファイルが生成されるので、これをGithubのpages用ブランチにプッシュしてgithub pagesとして公開できますし、もちろん他のホスティングサービスを利用することもできます。

今回はGithub pagesを利用するための設定を追加します。

gh-pagesの追加

node製のツールのgh-pagesを利用します。

Github pages用のブランチ作成や公開用ファイルだけをブランチとしてプッシュするなどの処理を自動化できて便利です。

gh-pages のインストール

yarn add gh-pages -D

サイト構成の設定

Github pagesでサイトを公開する場合、つぎのようなURLになります。

https://<ユーザID>.github.io/<リポジトリ名>/

サブディレクトリ対応をせずにデプロイすると404エラーなどが発生するため、gatsby-config.jsに設定を入れます。

便宜上、次のURLを例に説明します。

https://userid.github.io/mypage/

編集するのは次の二ヶ所です。

  1. module.exports = {の直下にpathPrefixを追加します。
  2. siteMetadata内のsiteUrlを変更します。

変更例

...
module.exports = {
+  pathPrefix: `/mypage/`,
  siteMetadata: {
    ...
-    siteUrl: `https://novela.narative.co`,
+    siteUrl: `https://userid.github.io`,
    ...

私自身はカスタムドメインで運用する予定のため、上記のサブディレクトリ構成はお試しで動作確認しただけです。一応404などのエラーは全て消えていましたがもし不都合など発生した場合はすみません。コメント頂けるとありがたいです。

ビルドしてデプロイする

次の手順でビルド+デプロイします。

# 念の為キャッシュをクリア
# npm cache clean
yarn cache clean

# ビルドを実施
gatsby build --prefix-paths

# gh-pagesを用いてデプロイ
# 環境によってはnpxが不要なケースもあると思います。
gh-pages -d public

Published と表示されればデプロイ成功です。

Github PagesのURLにアクセスし、さきほどローカルで確認したものと同じページが表示されていれば成功です。

あとはgatsby-config.jsonなどの情報を自分用に書き換えて、JAMStackなブログ生活をお楽しみください。

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

React + Redux でミドルウェアを使わないシンプルな非同期処理

はじめに

React.useEffectのみを持つコンポーネント(APIコンポーネント)を利用することで、非同期処理を行う方法を紹介します。APIコンポーネントを用いることで、非同期処理を含めたデータの流れがとてもシンプルになります。

APIコンポーネントとは

特徴

  1. propsを受けとらない
  2. Elementを返さない
  3. Redux Storeのみとデータのやり取りを行う
  4. APIReducer(APIコンポーネント専用のReducer)が管理するプロパティの更新を検知して非同期処理を開始する

SampleAPI.tsx

import * as React from 'react';
import { Dispatch, Action } from 'redux';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from 'store';

export const SampleAPI: React.FC<{}> = () => { // 1. propsを受けとらない
  /*
    3. Redux Storeのみとデータのやり取りを行う
  */
  const dispatch = useDispatch<Dispatch<Action>>();

  const data = useSelector<RootState, any>(
    state => state.sampleAPI.data
  );

  React.useEffect(() => {
    /*
      非同期処理
    */
  }, [data]); // 4. APIReducerが管理するプロパティの更新を検知して非同期処理を開始する

  return null; // 2. Elementを返さない
};

以降、最小構成のツイートアプリを用いて具体例を紹介します。 ソースコード(GitHub)

ツイート全体の取得 (UpdateTweetsAPI)

データの流れ

update_tweets.png
1. tweetsAPIReducerが管理するupdatingプロパティの更新をUpdateTweetsAPIが検知
2. UpdateTweetsAPIはサーバからツイート全体を取得
3. UpdateTweetsAPIはentitiesReducerが管理するtweetsプロパティに取得したツイート全体を保存
4. TweetListコンポーネントはtweetsプロパティの更新に伴って再描画。ツイートの一覧を表示

UpdateTweetsAPI.tsx

import * as React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Dispatch, Action } from 'redux';
import { RootState } from 'store';
import { entitiesActions } from 'actions/entitiesActions';
import { tweetsAPIActions } from 'actions/tweetsAPIActions';

/*
  サーバAPIとの通信用クライアント
*/
const fetchTweets = () => fetch('http://localhost/tweets', {
  method: 'GET',
  mode: 'cors',
  credentials: 'include',
});

export const UpdateTweetsAPI: React.FC<{}> = () => {
  const dispatch = useDispatch<Dispatch<Action>>();

  /*
    tweetsAPIReducerが管理するupdatingプロパティ(boolean型)
    非同期処理開始のトリガーとなるAPIReducerが管理するプロパティ
  */
  const updating = useSelector<RootState, boolean>(
    (state) => state.tweetsAPI.updating,
  );

  React.useEffect(() => {
    if (!updating) return;

    // 2. サーバからツイート全体を取得
    fetchTweets()
      .then((res) => res.json())
      .then((res) => {
        if (!res.tweets) return;

        // 3. entitiesReducerが管理するtweetsプロパティに取得したツイート全体を保存
        dispatch(entitiesActions.updateTweets(res.tweets));
      })
      .then(() => {
        /*
          非同期処理の終了をディスパッチ
          データの流れでは省略
        */
        dispatch(tweetsAPIActions.updateTweetsDone());
      })
      .catch(() => {
        dispatch(tweetsAPIActions.updateTweetsDone());
      });
  }, [updating]); // 1. updatingプロパティの更新を検知

  return null;
};

ツイートの送信 (SendTweetAPI)

データの流れ

send_tweet.png
1. TweetFormコンポーネントで送信ボタンが押されると、フォームの内容をtweetsAPIReducerが管理するnewContentプロパティに保存
2. newContentプロパティの更新をSendTweetAPIが検知
3. SendTweetAPIはサーバへツイートを送信
4. SendTweetAPIはupdateTweetsアクションをディスパッチすることで、tweetsAPIReducerが管理するupdatingプロパティを更新
5. updatingプロパティの更新をUpdateTweetsAPIが検知 (以後、上記のツイート全体の取得)

SendTweetAPI.tsx

import * as React from 'react';
import { Dispatch, Action } from 'redux';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from 'store';
import { tweetsAPIActions } from 'actions/tweetsAPIActions';

/*
  サーバAPIとの通信用クライアント
*/
const sendTweet = (content: string) => fetch('http://localhost/tweets', {
  method: 'POST',
  mode: 'cors',
  credentials: 'include',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ content }),
});

export const SendTweetAPI: React.FC<{}> = () => {
  const dispatch = useDispatch<Dispatch<Action>>();

  /*
    tweetsAPIReducerが管理するnewContentプロパティ(string型)
    非同期処理開始のトリガーとなるAPIReducerが管理するプロパティ
  */
  const newContent = useSelector<RootState, string>(
    (state) => state.tweetsAPI.newContent,
  );

  React.useEffect(() => {
    if (newContent === '') return;

    // 3. サーバへツイートを送信
    sendTweet(newContent)
      .then(() => {
        /*
          4. SendTweetAPIはupdateTweetsアクションをディスパッチすることで、
             tweetsAPIReducerが管理するupdatingプロパティを更新
        */
        dispatch(tweetsAPIActions.updateTweets());
      })
      .then(() => {
        /*
          非同期処理の終了をディスパッチ
          データの流れでは省略
        */
        dispatch(tweetsAPIActions.sendTweetDone());
      })
      .catch(() => {
        dispatch(tweetsAPIActions.sendTweetDone());
      });
  }, [newContent]); // 2. newContentプロパティの更新を検知

  return null;
};

おわりに

APIコンポートを利用すると、非同期処理をReduxデータフローの中に組み込むことができます。
また、以下のようにコンポーネントとして配置するだけで、APIコンポートはそのまま機能します。

/* TweetPanel.tsx */
import * as React from 'react';
import { TweetForm } from 'containers/TweetFormCTR';
import { TweetList } from 'containers/TweetListCTR';
import { UpdateTweetsAPI } from 'api/UpdateTweetsAPI';
import { SendTweetAPI } from 'api/SendTweetAPI';

export const TweetPanel: React.FC<{}> = () => (
  <div>
    <TweetForm />
    <TweetList />
    <UpdateTweetsAPI />
    <SendTweetAPI />
  </div>
);

再掲: 最小構成のツイートアプリ(GitHub)

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

Reactで遷移先のページ位置を一番上にする

問題

Reactでリンク先に遷移した時に、ページの途中や最後の位置を表示してしまう。

原因

Reactは、リンク経由でページ遷移をしても画面を書き換えるだけで、ページのスクロールポジションまでは初期化してくれないらしい。

解決策

マウント後に window.scrollTo(0, 0) を実行できるようにする。

一番簡単なのは useEffect を使う事ではないだろうか。
componentDidMount でも良いが、今回はモダンな useEffect で実装する。

index.js
import React, { useEffect } from 'react'

export default function Sample() {

  useEffect(() => {
      window.scrollTo(0, 0)
  }, []);

  return (
    // return something...
  )
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React で検索キーワードフィルター入力をいい感じにしてみた

はじめに

タイトルの通り、ReactでInputフィールドに全角入力された時に綺麗に動かなかったので、IME対応メモです。

ざっくり環境はこんな感じです。

ライブラリ バージョン
React ^16.10.2
Material-UI ^3.8.3
react-redux ^6.0.0

仕様は以下のUML図の通り、

  1. Inputフィールドに入力
  2. Inputフィールドに入力した内容でAPIリクエスト
  3. APIのレスポンスをstoreに保存
  4. storeからpropsに渡してレンダリング

alt

困ったこと

その1 全角入力問題

Material-UIのInput API、onChangeを使って、毎度APIにfetchしにいくのですが、
全角入力の場合、「テスト」と入力すると、「t、て、てs、てす、てすt、テスト...」全てでfetchされるわけです。。

入力速度によっては、「テスト」と入力しているのですが、
「テスt」のような入力が確定以前の状態でfetchした情報がstoreに保存されてしまうという問題が発生しました。(なんでーーー。)
画面上では、「テスト」で検索かけているつもりが、受け取っているデータは「テスt」でリクエストしたデータになっていておかしい。。。

フロントのみでフィルターして対応できれば良かったが、仕様の関係でAPIリクエストする必要がある...どうしようと言ったわけです。。
(技術的な負債を考慮したUIとか、まぁ、いろいろな問題はここでは置いておいて、)

その2 フロントが重い

Inputフィールドに変更が行われるたびに、onChangeが走るので、何度も何度もAPIリクエストが走ります。
重複した多重リクエストにフロント側の入力の挙動もかくかくなっちゃって良くない...

こういった、「いい感じ」にするための地味なところって結構辛い時多いです。
そもそも不要なリクエストが制限できれば理想。。。

回避策

その1 全角入力中はAPIリクエストしない

onCompositionStart&onCompositionEndメソッドを活用して回避しました。

原因としては、入力未確定状態でリクエスト投げて(しかも何度も)しまうことだったわけです。
全角入力開始時にonCompositionStartメソッドは発火、全握入力確定時にonCompositionEndメソッドが発火してくれるみたいです。

Material-UIの公式ドキュメントに用意されているメソッドではなくて、Inputタグが持つイベントのReactバージョンとして用意されているようです。
参照:公式Reactリファレンス(composition events)

具体的な回避策

onChangeメソッドで全角入力中かどうかのstate値を見て、falseの場合のみfetchするように変更
半角入力には特に制限はしていないので、半角入力時の多重リクエストは回避できません。。。泣

onChange = ({ target: { value: keyword }) => {
  this.setState({ keyword }, () => {
    if(!this.state.isIME) {
      this.props.fetchthis.state.keyword
    }
  }) 
}
onCompositionStart = () => {
  this.setState({ isIME: true })
}
onCompositionEnd = () => {
  this.setState({ isIME: false }})
}

...
<Input
  name="keyword"
  type="text"
  onChange={this.onChange}
  onCompositionStart={this.onCompositionStart}
  onCompositionEnd={this.onCompositionEnd}
  value={this.state.keyword}
>
...

これでなんとか理想の動作は確認できました。

その2 入力中をいい感じに取得して多重リクエストを回避

setTimeout()を使って、APIリクエストを行うようにしました。
onChangeが走ったタイミングで、入力中というステータスを管理するstateを生成。

setTImeout(fetch, 800)と、0.8秒など適当な間隔で、fetchさせるようにします。
(0.8秒以内の再リクエストはまだ入力中だと判断させる)
多重リクエストを避けるために、onChange発火タミング時にタイマーをリセットさせることで、回避させました。

これで、0.8秒以内に新しくリクエストが発生しそうな場合も前回リクエストを停止させます。

具体的な回避策

これで、0.8秒以内のテキスト入力時はAPIリクエストされずに、無駄な処理も走りません。
フロントもちょっと軽くすることができた。

onChange = ({ target: { value: keyword } }) => {
    clearTimeout(this.timer)
    this.setState(
      {
        isChange: true,
        keyword,
      },
      () => {
        this.timer = setTimeout(() => {
          this.setFilterState()
        }, 800)
      },
    )
  }

...
<Input
  name="keyword"
  type="text"
  onChange={this.onChange}
  value={this.state.keyword}
>
...

さいごに

処理重いって結構ユーザーにとって、嫌われる割合高いので(自分も嫌)、開発者として軽い動作だったり、滑らかなインタラクションというは気がけて行きたいなと思います。
setTImeout()とかって結構クールじゃない感じのメソッドという印象が強くて、あまり使いたくないんですが、今回は結構いい使い方できたのでは?と勝手に思ってます。

他にもっといいやり方あるとか、そもそもダメだろってところもあるかもなので、そいうのコメントとかしてもらえたら感謝です。。。

蛇足

Reactやっていると、componentDidUpdataとか、ループ並みに処理走っちゃうことがよくあるのですが、致し方ないものなのだろうか。。。
この辺綺麗にかけるように設計練り練りすべきなのだろうか...。

React初めてもうすぐ1年くらい経ちますが、まだまだ慣れないし、難しいなぁと思ってます。
(Hooksもやらなきゃ、redux卒業しないと...)

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

React で検索キーワードフィルター全角入力でいろいろ困った件について

はじめに

タイトルの通り、ReactでInputフィールドに全角入力された時に綺麗に動かなかったので、IME対応メモです。

ざっくり環境はこんな感じです。

ライブラリ バージョン
React ^16.10.2
Material-UI ^3.8.3
react-redux ^6.0.0

仕様は以下のUML図の通り、

  1. Inputフィールドに入力
  2. Inputフィールドに入力した内容でAPIリクエスト
  3. APIのレスポンスをstoreに保存
  4. storeからpropsに渡してレンダリング

alt

詰まったこと

Material-UIのInput API、onChangeを使って、毎度fetchするのですが、
全角入力の場合、「テスト」と入力すると、「t、て、てs、てす、てすt、テスト...」全てでfetchされるわけです。。

入力速度によっては、「テスト」と入力しているのですが、
「テスt」のような入力が確定以前の状態でfetchした情報がstoreに保存されてしまうという問題が発生しました。(なんでーーー。)
画面上では、「テスト」で検索かけているつもりが、受け取っているデータは「テスt」でリクエストしたデータになっていておかしい。。。

フロントのみでフィルターして対応できれば良かったが、仕様の関係でAPIリクエストする必要がある...どうしようと言ったわけです。。
(技術的な負債を考慮したUIとか、まぁ、いろいろな問題はここでは置いておいて、)

回避策

onCompositionStart&onCompositionEndメソッドを活用して回避しました。

原因としては、入力未確定状態でリクエスト投げて(しかも何度も)しまうことだったわけです。
全角入力開始時にonCompositionStartメソッドは発火、全握入力確定時にonCompositionEndメソッドが発火してくれるみたいです。

Material-UIの公式ドキュメントに用意されているメソッドではなくて、Inputタグが持つイベントのReactバージョンとして用意されているようです。
参照:公式Reactリファレンス(composition events)

具体的な回避策

onChangeメソッドで何度もfetchしちゃうところに関しては特にいじらず、
onCompositionEnd=全角入力確定時に新ためてfetchするようにしました。(この時点で確実に目的のキーワードが取れるはず!)
結局半角入力にも対応させたりするためには、このくらいしかできなそうだなと...。

onChange = ({ target: { value: keyword }) => {
  this.setState({ keyword }, () => {
    this.props.fetchthis.state.keyword
  }) 
}
onCompositionEnd = () => {
  this.props.fetchthis.state.keyword
}

...
<Input
  name="keyword"
  type="text"
  onChange={this.onChange}
  onCompositionEnd={this.onCompositionEnd}
  value={this.state.search}
>
...

これでなんとか理想の動作は確認できました。

さいごに

Reactやっていると、componentDidUpdataとか、ループ並みに処理走っちゃうことがよくあるのですが、致し方ないものなのだろうか。。。
この辺綺麗にかけるように設計練り練りすべきなのだろうか...。

React初めてもうすぐ1年くらい経ちますが、まだまだ慣れないし、難しいなぁと思ってます。
(Hooksもやらなきゃ、redux卒業しないと...)

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