- 投稿日:2020-07-31T21:47:44+09:00
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.jsmodule.exports = { plugins: [ // ...中略 { resolve: 'gatsby-plugin-graphql-codegen', options: { codegenConfig: { maybeValue: 'T | undefined' }, // これを追加! }, }, ] }どういうこと?
gatsby-plugin-graphql-codegen
は、GraphQL クエリから型生成する際、全ての戻り値を以下のMaybe
という型でラップします。graphql-types.tsexport type Maybe<T> = T | null;GraphQL から取得するデータは
null
になる可能性があるので、これは妥当な型定義ではあります。
TypeScript では Optional Chaining?.
を利用することで、このような Nullable な値に対しても安全に値を取得できることができます。例
Gatsby のデフォルトスターター gatsby-starter-default を例にとると、以下のように GraphQL クエリで画像を読み込んでいるコンポーネントがあります。
src/components/image.jsimport 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.tsximport 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 Image
)ImageQuery
という名前で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' に割り当てることはできません。要点を抜き出すと「
null
をundefined
に割り当てることはできません」と言っています。
gatsby-image
コンポーネントのImg
はundefined
を受け付けるように型定義されているのですが、null
は受け取ってくれないようです。
先程述べた通りgatsby-plugin-graphql-codegen
は全ての型をMaybe<T> = T | null
でラップするので、data.placeholderImage?.childImageSharp?.fluid
はnull
になりうると判断されてしまいます。
これは、以下のようにnull
だったらundefined
になるように書けば回避できます。src/components/image.tsxconst 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.jsmodule.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.tsxconst Image: React.FC = () => { // ...略 return <Img fluid={data.placeholderImage?.childImageSharp?.fluid} /> } export default Image型定義を変えて大丈夫なの?
null
を勝手にundefined
と扱ってしまって大丈夫なのかと思うかもしれませんが、基本的に問題はないと思っています。
型定義を変えるだけなので、少なくともトランスパイル後の JavaScript には影響はありません。
最近のライブラリならnull
とundefined
の違いで大きな問題が起こることはないと思っていますが、全てを確認したわけではないです。
ダメだった場合はごめんなさい。まとめ
gatsby-plugin-graphql-codegen
の生成するnull
に悩まされている方はcodegenConfig: { maybeValue: 'T | undefined' }
を設定すると快適になるかもしれません
"strict": true
にしている等 ↩
- 投稿日:2020-07-31T21:05:03+09:00
React Contextのメモ
親コンポーネントのstateの値を、孫コンポーネントのpropsの値として使う場合はこのような書き方になる。
index.jsimport 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.jsimport 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"));
- 投稿日:2020-07-31T20:46:59+09:00
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);対して使う方
ElementRef
でHandleableGreeting
から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
を持つように変わりますこのようにして、
ref
をHandler
型として扱うようになり、ElementRef
でその型を取ってきているというわけですまとめ
というわけで、useImperativeHandleを使ったコンポーネントの型のつけ方でした
がっつり調べたのでもう忘れないと思います
多分
- 投稿日:2020-07-31T19:24:47+09:00
【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 defaultReact 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)おわりに
もっともっとあるはず。これからどんどん追加していきます。
- 投稿日:2020-07-31T17:32:44+09:00
SharePointのWebパーツをReactベースで作る
はじめに
SharePointのWebパーツをReactで作れるらしいので、実際にやってみました。しばらくMSから離れていたのですが、私の知っている鎖国のような時代は終わっていて、オープンソースワールドがそこには広がっていました。MSでReactやgulpを使う日が来るなんて…。これをきっかけに、またいろいろと作ってみたくなりました。
Office UI Fabric React
Webパーツ自体は、JavaScriptフレームワークを使っても使わなくてもよいです。が、Reactベースの素晴らしすぎるUIフレームワークがあったので、これを使います。このコンポーネントを使うだけで、簡単にそれっぽいものが作れそうです。
今回作るWebパーツ
せっかくなので、SharePointのサイトコンテンツにアクセスするものを作ろうと思います。今回は、リストデータを取得して表示するパーツを作ります。使うコンポーネントは、DetailsListです。
やってみた
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 このようになれば、プロジェクトの作成は完了です。
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コンポーネントが表示されました。
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, }); }); } }ローカルでプレビューすると、こんな感じになります。モックストアでセットしたデータがちゃんと表示されています。
5. SharePointにWebパーツをデプロイする
次のコマンドでプロジェクトをビルドして、ソリューションをパッケージ化します。
gulp bundle --ship gulp package-solution --ship
sharepoint/solution/detailslist-webpart.sppkgというファイルが生成されると思います。あとは、これをサイトにアップロードして展開すると、実際にWebパーツとして利用できるようになります。
ちょっと今回は適当なサイトがなかったので、キャプチャなどは割愛します。実際に動かしてみた感じでは、とてもサクサク動くのでびっくりしました。
おわりに
ReactベースでSharePointのWebパーツを作成しました。
これを応用すると、結構いろんなパーツが作れるので、めちゃくちゃ活用できそうです。何でもっと早くやらなかったんだろうと思うくらい、控えめに言って最強なソリューションでした。
- 投稿日:2020-07-31T15:57:11+09:00
[React]React コンポーネントでJSXが複数行になる場合になぜ()で囲むのか?
はじめに
今更ですが、みなさんReactコンポーネントを作る際に「なんで、JSXが複数行になる場合にのみ()で囲まないといけないのか?」と疑問に思ったことはないでしょうか?
僕は夜も眠れないくらい気になったので、ちょっと調べてみました。
↓こんな場合の()です!
hello.jsimport 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/GroupingReact公式ドキュメントの見解
読みやすさのため 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.jsvar hello = <div> <h1>Hello</h1> </div>; const element = <Hello name="Sara" />; ReactDOM.render(element, document.getElementById('root'));=> 問題なく動作する。
例2: JSXをアロー関数でリターン
hello.jsconst 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.jsfunction 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.jsfunction 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.jsvar hello = ( <div> <p>Hello</p> </div> );JSXをアロー関数でリターン
hello.jsvar hello = () => ( <div> <p>World</p> </div> );JSXをfunction内でリターン
hello.jsfunction hello() { return ( <div> <p>Hello</p> </div> ); }参考:
JavaScript Standard Styleでの方針
- https://github.com/standard/standard/issues/710
- https://github.com/standard/standard/commit/ccaf4390d9ae0829fdd31b2d69df143e9138e77dEsLint 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" }
parens
とparens-new-line
の違いどちらも()で囲おうというのは同じなのですが、
parens-new-line
の方が少し厳しいです。どちらもOK
hello.jsvar hello = ( <div> <p>Hello</p> </div> );どちらもNG
hello.jsvar hello = <div> <p>Hello</p> </div>;
parens
ではOKだが、parens-new-line
でNGhello.jsvar hello = (<div> <p>Hello</p> </div>);最後に
ReactでJSXが複数行になる場合には()をつけましょう!
夜、()に悩まずに眠れるようになった人が少しでも増えたなら、幸いです。
- 投稿日:2020-07-31T15:32:33+09:00
React Hook Formでファイルアップロードを実装する
概要
React Hook Formというライブラリを使って、Reactでアンケートフォームを実装しました。
その中で画像アップロードを実装した際に、様々な工夫を行ったのでその記録を残します。イメージ
要求仕様
- アップロードできる画像サイズの合計は10MB以内
- アップロードする画像のプレビューが必要
- アップロードできる画像(=プレビューの画像)の枚数は3枚
- 同じ画像はアップロードしないように弾く
- 画像のみアップロードする(jpeg, png, bmp, gif, svgのみ)
- それぞれの条件に反した場合には、エラーを表示する
React Hook Formを活用した背景
- 画像アップロード以外に、入力が必要な項目が存在したため
- ReactにおいてFormの実装がやりやすく、パフォーマンスも高いため
手順
- 画像アップロード部分のコンポーネント(
PhotosUpload.tsx
)の作成- フォームを表示するページ(
Questionnaire.tsx
)の作成工夫点
アップロードできる画像サイズの合計は10MB以内
→画像を圧縮することで、サイズを気にすることなくアップロードできるようにしました
1枚あたり、3MBに圧縮する処理をbrowser-image-compressionを用いて行いました。アップロードする画像のプレビューが必要
→画像アップロードのコンポーネントで、選択された画像をstateの配列で管理し、その配列によってDOMを出し分けましたアップロードできる画像(=プレビューの画像)の枚数は3枚
→上記stateの配列のlengthを3以下に制限することで、アップロードできる(プレビューできる)画像を3枚に制限しました同じ画像はアップロードしないように弾く
→同じサイズの画像は配列に追加できないというロジックで実装しました
最初は画像の名前で弾く実装を行っていたのですが、safariで予想通りの挙動を示さなかったため変更しました。
safariではheifの画像を選択した場合、jpegに変換する処理が走るようで、その際に画像名が自動的に付けられてしまうためであることが分かりました。
より厳密に行うには、lastModifiedなど追加の情報を使うか、exifを読み込んで別の情報を取得することで、固有性を正確に担保できます。画像のみアップロードする(jpeg, png, bmp, gif, svgのみ)
→多くのブラウザではinputタグのacceptプロパティで"image/*"
を指定すればいいですが、より厳密に行うために上記の配列への追加の際にfileのtypeプロパティで対象外のファイルを弾きました
多くのスマートフォンのブラウザでは、rawやheifなどが"image/*"
の対象となります。ただ一方で、imgタグではこれらの画像を表示することができません。そのため、今回これらの画像は対象外とし、配列の追加の際に弾く実装を行いました。
ただ前述の通り、safariではheifはjpegに変換されるため利用できます。(Androidではheifは選択できるものの、変換されないため、今回の場合は対象外になります。)
ちなみにPCのブラウザではrawやheifなどは選択の際に無効になります。それぞれの条件に反した場合には、エラーを表示する
上記、①3枚以内か、②同じ画像ではないか、③対象のファイルタイプかの3点それぞれについて、エラーを保持するstateを準備し、それぞれのエラーが発生した際にtrueとすることで、それをきっかけにエラーのDOMを出し分けました。実装
PhotosUpload.tsximport 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.tsximport { 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;参考
- 投稿日:2020-07-31T15:32:33+09:00
React Hook Formで画像アップロードを実装する
概要
React Hook Formというライブラリを使って、Reactでアンケートフォームを実装しました。
その中で画像アップロードを実装した際に、様々な工夫を行ったのでその記録を残します。イメージ
要求仕様
- アップロードできる画像サイズの合計は10MB以内
- アップロードする画像のプレビューが必要
- アップロードできる画像(=プレビューの画像)の枚数は3枚
- 同じ画像はアップロードしないように弾く
- 画像のみアップロードする(jpeg, png, bmp, gif, svgのみ)
- それぞれの条件に反した場合には、エラーを表示する
React Hook Formを活用した背景
- 画像アップロード以外に、入力が必要な項目が存在したため
- ReactにおいてFormの実装がやりやすく、パフォーマンスも高いため
手順
- 画像アップロード部分のコンポーネント(
PhotosUpload.tsx
)の作成- フォームを表示するページ(
Questionnaire.tsx
)の作成工夫点
アップロードできる画像サイズの合計は10MB以内
→画像を圧縮することで、サイズを気にすることなくアップロードできるようにしました
1枚あたり、3MBに圧縮する処理をbrowser-image-compressionを用いて行いました。アップロードする画像のプレビューが必要
→画像アップロードのコンポーネントで、選択された画像をstateの配列で管理し、その配列によってDOMを出し分けましたアップロードできる画像(=プレビューの画像)の枚数は3枚
→上記stateの配列のlengthを3以下に制限することで、アップロードできる(プレビューできる)画像を3枚に制限しました同じ画像はアップロードしないように弾く
→同じサイズの画像は配列に追加できないというロジックで実装しました
最初は画像の名前で弾く実装を行っていたのですが、safariで予想通りの挙動を示さなかったため変更しました。
safariではheifの画像を選択した場合、jpegに変換する処理が走るようで、その際に画像名が自動的に付けられてしまうためであることが分かりました。
より厳密に行うには、lastModifiedなど追加の情報を使うか、exifを読み込んで別の情報を取得することで、固有性を正確に担保できます。画像のみアップロードする(jpeg, png, bmp, gif, svgのみ)
→多くのブラウザではinputタグのacceptプロパティで"image/*"
を指定すればいいですが、より厳密に行うために上記の配列への追加の際にfileのtypeプロパティで対象外のファイルを弾きました
多くのスマートフォンのブラウザでは、rawやheifなどが"image/*"
の対象となります。ただ一方で、imgタグではこれらの画像を表示することができません。そのため、今回これらの画像は対象外とし、配列の追加の際に弾く実装を行いました。
ただ前述の通り、safariではheifはjpegに変換されるため利用できます。(Androidではheifは選択できるものの、変換されないため、今回の場合は対象外になります。)
ちなみにPCのブラウザではrawやheifなどは選択の際に無効になります。それぞれの条件に反した場合には、エラーを表示する
上記、①3枚以内か、②同じ画像ではないか、③対象のファイルタイプかの3点それぞれについて、エラーを保持するstateを準備し、それぞれのエラーが発生した際にtrueとすることで、それをきっかけにエラーのDOMを出し分けました。実装
PhotosUpload.tsximport 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.tsximport { 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;参考
- 投稿日:2020-07-31T12:46:35+09:00
Hooks APIを使ったFunctional Component間のデータやイベントのやり取り
はじめに
React初級者ぐらいの私が、Functional ComponentでHooks APIを使って以下をどう書くか知らべた結果を書きます。
- 親と孫でstateを共通管理する
- 親から孫の関数を呼び出す
- 子は、上記に関することを何も書かない
- React-Redux ってどうなる?
以下のHooks APIを使いました
- useState
- useContext
- useReducer
成果物
公式にもよく出てくる、Counterを+や-ボタンで増減させるアプリです。
ここでコード参照できます。
背景
個々の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.jsexport default function ParentComponent() { const [state, dispatch] = useReducer(reducer, initialState)※
reducer
とinitialState
はrecuder.jsに切り出しました。
state
やdispatch
を親component配下のcomponent達で参照できる用contextを用意します。parent.jsimport {ContextAppDispatch, ContextState} from "./context.js"context.js は、孫でも参照するので外部化しました。
context.jsimport React from "react" export const ContextAppDispatch = React.createContext("AppDispatch"); export const ContextState = React.createContext("state");定義したものを、配下のcomponentが受け取れるようにします。
parent.jsreturn ( <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.jsimport {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.jsimport 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.jsexport 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'}
も外部化したほうが良さそう
- 投稿日:2020-07-31T12:46:35+09:00
【React.js】Hooks APIを使ったFunctional Component間のデータやイベントのやり取り
はじめに
React初級者ぐらいの私が、Functional ComponentでHooks APIを使って以下をどう書くか知らべた結果を書きます。
- 親と孫でstateを共通管理する
- 親から孫の関数を呼び出す
- 子は、上記に関することを何も書かない
- React-Redux ってどうなる?
以下のHooks APIを使うことで実現できました。
- useState
- useContext
- useReducer
成果物
公式にもよく出てくる、Counterを+や-ボタンで増減させるアプリです。
ここでコード参照できます。
背景
個々の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.jsexport default function ParentComponent() { const [state, dispatch] = useReducer(reducer, initialState)※
reducer
とinitialState
はreducer.jsに切り出しました。
state
やdispatch
を親component配下のcomponent達で参照できる用contextを用意します。parent.jsimport {ContextAppDispatch, ContextState} from "./context.js"context.js は、孫でも参照するので外部化しました。
context.jsimport React from "react" export const ContextAppDispatch = React.createContext("AppDispatch"); export const ContextState = React.createContext("state");定義したものを、配下のcomponentが受け取れるようにします。
parent.jsreturn ( <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.jsimport {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.jsimport 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.jsexport 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'}
も外部化したほうが良さそう
- 投稿日:2020-07-31T12:37:27+09:00
TrelloみたいなカンバンUIを作りたいので、Javascriptのドラッグ&ドロップについて調べてみた
Trelloのようなタスク管理で、タスクをドラッグ&ドロップで移動する操作がありますよね。
今までドラッグ&ドロップの処理を書いたことがないので、調べてみました。作ったサンプル
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を使うなら、こうした方がいい的な改善等ありましたら、
ぜひご指摘ください。
- 投稿日:2020-07-31T07:32:35+09:00
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を利用します。
下のようなテーマです。こちらでLiveデモを確認できます。
テーマを取得
ここでgatsby-theme-novelaをcloneしたいところですがREADMEを読むとgatsby-startarのためのリポジトリは別に存在します。なので次のリポジトリをcloneしてください。
git clone git@github.com:narative/gatsby-starter-novela.git <ローカルフォルダ名>セットアップと確認用サーバ起動
READMEの通りにコマンドを実行します。
cd <ローカルフォルダ名> yarn yarn global add gatsby yarn dev次のURLで表示されれば成功です。
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をダウンロードします。
コピー&ペーストでも良いですが、直接ダウンロードする場合は次のようにします。
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.jsonThe 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
*画像などのメディアが一つも登録されていないとGatsbyでデータを取得する際にエラーが発生する場合があるようです。著者アイコンはとりあいず入れておいた方がよさそうです。
Article(記事情報)のサンプルcontent
(画像ではモデル名がPostとなっていますが、Articleと読み替えてください)
どれも一般的な項目ですが、いわゆるアイキャッチ画像のフィールド名が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 dotenvgatsby-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の内容が表示されれば成功です。
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/
編集するのは次の二ヶ所です。
module.exports = {
の直下にpathPrefix
を追加します。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 publicPublished と表示されればデプロイ成功です。
Github PagesのURLにアクセスし、さきほどローカルで確認したものと同じページが表示されていれば成功です。
あとはgatsby-config.jsonなどの情報を自分用に書き換えて、JAMStackなブログ生活をお楽しみください。
- 投稿日:2020-07-31T05:43:41+09:00
React + Redux でミドルウェアを使わないシンプルな非同期処理
はじめに
React.useEffectのみを持つコンポーネント(APIコンポーネント)を利用することで、非同期処理を行う方法を紹介します。APIコンポーネントを用いることで、非同期処理を含めたデータの流れがとてもシンプルになります。
APIコンポーネントとは
特徴
- propsを受けとらない
- Elementを返さない
- Redux Storeのみとデータのやり取りを行う
- 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)
データの流れ
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)
データの流れ
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> );
- 投稿日:2020-07-31T02:01:03+09:00
Reactで遷移先のページ位置を一番上にする
問題
Reactでリンク先に遷移した時に、ページの途中や最後の位置を表示してしまう。
原因
Reactは、リンク経由でページ遷移をしても画面を書き換えるだけで、ページのスクロールポジションまでは初期化してくれないらしい。
解決策
マウント後に
window.scrollTo(0, 0)
を実行できるようにする。一番簡単なのは
useEffect
を使う事ではないだろうか。
componentDidMount
でも良いが、今回はモダンなuseEffect
で実装する。index.jsimport React, { useEffect } from 'react' export default function Sample() { useEffect(() => { window.scrollTo(0, 0) }, []); return ( // return something... ) }
- 投稿日:2020-07-31T00:34:15+09:00
React で検索キーワードフィルター入力をいい感じにしてみた
はじめに
タイトルの通り、ReactでInputフィールドに全角入力された時に綺麗に動かなかったので、IME対応メモです。
ざっくり環境はこんな感じです。
ライブラリ バージョン React ^16.10.2 Material-UI ^3.8.3 react-redux ^6.0.0 仕様は以下のUML図の通り、
- Inputフィールドに入力
- Inputフィールドに入力した内容でAPIリクエスト
- APIのレスポンスをstoreに保存
- storeからpropsに渡してレンダリング
困ったこと
その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.fetch(this.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卒業しないと...)
- 投稿日:2020-07-31T00:34:15+09:00
React で検索キーワードフィルター全角入力でいろいろ困った件について
はじめに
タイトルの通り、ReactでInputフィールドに全角入力された時に綺麗に動かなかったので、IME対応メモです。
ざっくり環境はこんな感じです。
ライブラリ バージョン React ^16.10.2 Material-UI ^3.8.3 react-redux ^6.0.0 仕様は以下のUML図の通り、
- Inputフィールドに入力
- Inputフィールドに入力した内容でAPIリクエスト
- APIのレスポンスをstoreに保存
- storeからpropsに渡してレンダリング
詰まったこと
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.fetch(this.state.keyword) }) } onCompositionEnd = () => { this.props.fetch(this.state.keyword) } ... <Input name="keyword" type="text" onChange={this.onChange} onCompositionEnd={this.onCompositionEnd} value={this.state.search} > ...これでなんとか理想の動作は確認できました。
さいごに
Reactやっていると、
componentDidUpdata
とか、ループ並みに処理走っちゃうことがよくあるのですが、致し方ないものなのだろうか。。。
この辺綺麗にかけるように設計練り練りすべきなのだろうか...。React初めてもうすぐ1年くらい経ちますが、まだまだ慣れないし、難しいなぁと思ってます。
(Hooksもやらなきゃ、redux卒業しないと...)