8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Next.jsをReasonMLで用いてページ遷移しWeb APIを叩きレスポンスを表示する

Last updated at Posted at 2020-06-07

Next.jsとは

Next.js
Next.jsはReact.jsを用いたフロントエンドJavascriptフレームワークです。
SSRが容易に実現できSEO対策として優れる点やファイル構成がそのままページ構成になるので、ルーティングが楽な点が好きです。

ReasonMLとは

Reason
OCamlベースの言語です。BuckleScriptによってJSにトランスパイルされます。
静的型付けでイミュータル、そして関数型言語らしくパターンマッチングが使えます。
Facebookの技術なので、もちろん(?)JSXにも対応してます。
FacebookはOCamlが好きで、JSの静的型チェッカーFlowも大部分がOcamlで書かれています。

ReasonMLを用いる動機

Web開発をするにあたってJS/AltJSを考えると、ECMAScript, TypeScript, ElmそしてBuckleScript/ReasonMLと選択肢があり、Reactを使うことを考えるとElmは使えず、ECMAScript+Flow, TypeScript, BuckleScript/ReasonMLのどれかになります。
ECMAScript+FlowもTypeScriptも使う人が多く、特に困ることはないと思います。
私自身、最近Haskellを勉強していて、関数型を学んでいくとオブジェクトがイミュータブルだったりパターンマッチングが使えるReasonMLが良いのではと思った次第です。

初期セットアップ

Example app using ReasonML & ReasonReact components
実はNext.jsのリポジトリの中に説明が用意されています。
これに従ってコマンドを実行します。

$ yarn create next-app --example with-reasonml <任意の新規作成アプリ名>

フォルダ構成は以下の通り

.
├── README.md
├── bindings
│   └── Next.re
├── bsconfig.json
├── components
│   ├── Counter.re
│   ├── GlobalCount.re
│   └── Header.re
├── index.js
├── next.config.js
├── node_modules(略)
├── package.json
├── pages
│   ├── about.re
│   └── index.re
├── tree.txt
└── yarn.lock

binding/Next.reにNext.jsとをReasonMLから利用するためのモジュール設定が書かれています。
しかし、これらが全てではないので、足りない場合はJSのコードを参照して補わないといけません。

package.jsonないのscriptsにコマンドが書かれています。

$ yarn dev

で開発モードでサーバーが起動します。
http://localhost:3000/
でサンプルページが開けるので確認しましょう。

ss_index.png

#sampleページを作る

$ yarn devが走っている状態で新しくページを追加してみましょう。
pages内にsample.reを作成します。

sample.re
[@react.component]
let make = () => {
  <div> <p> {ReasonReact.string("test")} </p> </div>;
};

let default = make;

保存すると、再度トランスパイルが走り、
http://localhost:3000/sample
でページが表示されます。

ss_sample.png

この手軽さがNext.jsの良いところです。

make関数はreact.componentを返り値に持つ関数で、これをdefaultに渡しています。
ReasonMLの関数では最後の値が返り値になります。パターンマッチングを用いることが多く、アーリーリターンは使わないようです。
JavaScript版(TypeScript含む。以後JS版)で関数コンポーネントと称されていたものと似てますね。
関数コンポーネントとクラスコンポーネント

トランスパイルされたものを見てみましょう

sample.bs.js
// Generated by BUCKLESCRIPT, PLEASE EDIT WITH CARE

import * as React from "react";

function Sample(Props) {
  return React.createElement("div", undefined, React.createElement("p", undefined, "テスト"));
}

var make = Sample;

var $$default = Sample;

export {
  make ,
  $$default ,
  $$default as default,
  
}
/* react Not a pure module */

ReasonMLで面倒なのが、{ReasonReact.string("test")}の部分です。
文字列を出力するにあたっていちいち囲まなくてはいけません。
また、"test""テスト"に置き換えた時、文字化けします。

ss_sample_utf8.png

これを回避するために、エスケープしてあげます。
ダブルクオーテーション("")の代わりに、{js||js}あるいは{j||j}でくくります。
String & Character - Reason

pages/sample.re
[@react.component]
let make = () => {
  <div> <p> {ReasonReact.string({js|test|js})} </p> </div>;
};

let default = make;

ss_sample_utf8_escape.png

NextRouterで遷移

Next.jsで画面を遷移させる方法として、JSXにLinkを埋め込む方法と関数内で遷移させる方法が考えられます。
Next.js - Routing

Next.Link

アプリを作成したときに生成されるcomponentsフォルダのHeader.reを見てください。

components/Header.re
let styles = ReactDOMRe.Style.make(~marginRight="10px", ());

[@react.component]
let make = () => {
  <div>
    <Next.Link href="/">
      <a style=styles> {ReasonReact.string("Home")} </a>
    </Next.Link>
    <Next.Link href="/about">
      <a style=styles> {ReasonReact.string("About")} </a>
    </Next.Link>
  </div>;
};

let default = make;

<Next.Link href="/">(中略)</Next.Link><Next.Link href="/about">(中略)</Next.Link>で指定される部分がリンクです。

ss_index_link.png

文字列をクリックすると遷移できます。

関数内で制御(Router.push)

モジュール接続

関数内で遷移させる場合、useRouterを用いてrouterオブジェクトにアクセスします。
Next.js - useRouter

このページにある例を見てみます。

active_link.js
import { useRouter } from 'next/router'

function ActiveLink({ children, href }) {
  const router = useRouter()
  const style = {
    marginRight: 10,
    color: router.pathname === href ? 'red' : 'black',
  }

  const handleClick = (e) => {
    e.preventDefault()
    router.push(href)
  }

  return (
    <a href={href} onClick={handleClick} style={style}>
      {children}
    </a>
  )
}

export default ActiveLink

ファイル冒頭でimport { useRouter } from 'next/router'のよりuseRouterをインポートし、
function内でconst router=useRouter()で呼び出し、
handleClick内でrouter.push(href)で遷移させるようになっています。

では、これをReasonMLで実現するためにはどうすればよいでしょうか。

初期セットアップの段でNext.jsのモジュール設定はNext.reにあると説明しました。ということでまず、bindings/Next.reを参照し、中身を確認します。

bindings/Next.re
module Link = {
  [@bs.module "next/link"] [@react.component]
  external make:
    (
      ~href: string=?,
      ~_as: string=?,
      ~prefetch: option(bool)=?,
      ~replace: option(bool)=?,
      ~shallow: option(bool)=?,
      ~passHref: option(bool)=?,
      ~children: React.element
    ) =>
    React.element =
    "default";
};

module Head = {
  [@bs.module "next/head"] [@react.component]
  external make: (~children: React.element) => React.element = "default";
};

module Error = {
  [@bs.module "next/head"] [@react.component]
  external make: (~statusCode: int, ~children: React.element) => React.element =
    "default";
};

どうもサンプルプロジェクト用の最低限のモジュール分しか実装されていないようです。
では、useRouterを実装するべく、モジュール本体を確認しましょう。
まず、node_modules/next/router.d.tsをチェックします。

node_modules/next/router.d.ts
export * from './dist/client/router'
export { default } from './dist/client/router'

すると、別の場所からインポートしてエクスポートしているようなので、該当の
node_modules/next/dist/client/router.d.tsを見ます。

node_modules/next/dist/client/router.d.ts
/// <reference types="node" />
import React from 'react';
import Router, { NextRouter } from '../next-server/lib/router/router';
declare type SingletonRouterBase = {
    router: Router | null;
    readyCallbacks: Array<() => any>;
    ready(cb: () => any): void;
};
export { Router, NextRouter };
export declare type SingletonRouter = SingletonRouterBase & NextRouter;
declare const _default: SingletonRouter;
export default _default;
export { default as withRouter } from './with-router';
export declare function useRouter(): NextRouter;
export declare const createRouter: (pathname: string, query: import("querystring").ParsedUrlQuery, as: string, __3: {
    subscription: (data: {
        Component: React.ComponentType<{}>;
        __N_SSG?: boolean | undefined;
        __N_SSP?: boolean | undefined;
        props?: any;
        err?: Error | undefined;
        error?: any;
    }, App?: React.ComponentClass<{}, any> | React.FunctionComponent<{}> | undefined) => Promise<void>;
    initialProps: any;
    pageLoader: any;
    Component: React.ComponentType<{}>;
    App: React.ComponentType<{}>;
    wrapApp: (App: React.ComponentType<{}>) => any;
    err?: Error | undefined;
    isFallback: boolean;
}) => Router;
export declare function makePublicRouterInstance(router: Router): NextRouter;

userRouterが返すNextRouter型はnode_modules/next-server/lib/router/router.d.tsに記載があるようです。

node_modules/next-server/lib/router/router
/// <reference types="node" />
import { ParsedUrlQuery } from 'querystring';
import { ComponentType } from 'react';
import { UrlObject } from 'url';
import { MittEmitter } from '../mitt';
import { NextPageContext } from '../utils';
export declare function addBasePath(path: string): string;
export declare function delBasePath(path: string): string;
declare type Url = UrlObject | string;
declare type ComponentRes = {
    page: ComponentType;
    mod: any;
};
export declare type BaseRouter = {
    route: string;
    pathname: string;
    query: ParsedUrlQuery;
    asPath: string;
    basePath: string;
};
export declare type NextRouter = BaseRouter & Pick<Router, 'push' | 'replace' | 'reload' | 'back' | 'prefetch' | 'beforePopState' | 'events' | 'isFallback'>;
export declare type PrefetchOptions = {
    priority?: boolean;
};
declare type RouteInfo = {
    Component: ComponentType;
    __N_SSG?: boolean;
    __N_SSP?: boolean;
    props?: any;
    err?: Error;
    error?: any;
};
declare type Subscription = (data: RouteInfo, App?: ComponentType) => Promise<void>;
declare type BeforePopStateCallback = (state: any) => boolean;
declare type ComponentLoadCancel = (() => void) | null;
declare type HistoryMethod = 'replaceState' | 'pushState';
export default class Router implements BaseRouter {
    route: string;
    pathname: string;
    query: ParsedUrlQuery;
    asPath: string;
    basePath: string;
    /**
     * Map of all components loaded in `Router`
     */
    components: {
        [pathname: string]: RouteInfo;
    };
    sdc: {
        [asPath: string]: object;
    };
    sub: Subscription;
    clc: ComponentLoadCancel;
    pageLoader: any;
    _bps: BeforePopStateCallback | undefined;
    events: MittEmitter;
    _wrapApp: (App: ComponentType) => any;
    isSsr: boolean;
    isFallback: boolean;
    static events: MittEmitter;
    constructor(pathname: string, query: ParsedUrlQuery, as: string, { initialProps, pageLoader, App, wrapApp, Component, err, subscription, isFallback, }: {
        subscription: Subscription;
        initialProps: any;
        pageLoader: any;
        Component: ComponentType;
        App: ComponentType;
        wrapApp: (App: ComponentType) => any;
        err?: Error;
        isFallback: boolean;
    });
    static _rewriteUrlForNextExport(url: string): string;
    onPopState: (e: PopStateEvent) => void;
    update(route: string, mod: any): void;
    reload(): void;
    /**
     * Go back in history
     */
    back(): void;
    /**
     * Performs a `pushState` with arguments
     * @param url of the route
     * @param as masks `url` for the browser
     * @param options object you can define `shallow` and other options
     */
    push(url: Url, as?: Url, options?: {}): Promise<boolean>;
    /**
     * Performs a `replaceState` with arguments
     * @param url of the route
     * @param as masks `url` for the browser
     * @param options object you can define `shallow` and other options
     */
    replace(url: Url, as?: Url, options?: {}): Promise<boolean>;
    change(method: HistoryMethod, _url: Url, _as: Url, options: any): Promise<boolean>;
    changeState(method: HistoryMethod, url: string, as: string, options?: {}): void;
    getRouteInfo(route: string, pathname: string, query: any, as: string, shallow?: boolean): Promise<RouteInfo>;
    set(route: string, pathname: string, query: any, as: string, data: RouteInfo): Promise<void>;
    /**
     * Callback to execute before replacing router state
     * @param cb callback to be executed
     */
    beforePopState(cb: BeforePopStateCallback): void;
    onlyAHashChange(as: string): boolean;
    scrollToHash(as: string): void;
    urlIsNew(asPath: string): boolean;
    /**
     * Prefetch page code, you may wait for the data during page rendering.
     * This feature only works in production!
     * @param url the href of prefetched page
     * @param asPath the as path of the prefetched page
     */
    prefetch(url: string, asPath?: string, options?: PrefetchOptions): Promise<void>;
    fetchComponent(route: string): Promise<ComponentRes>;
    _getData<T>(fn: () => Promise<T>): Promise<T>;
    _getStaticData: (asPath: string) => Promise<object>;
    _getServerData: (asPath: string) => Promise<object>;
    getInitialProps(Component: ComponentType, ctx: NextPageContext): Promise<any>;
    abortComponentLoad(as: string): void;
    notify(data: RouteInfo): Promise<void>;
}
export {};

これでuseRouterを用いるのに必要な型情報がわかりましたね!!
じゃあこれに基づいてNext.reに実装していきましょう、と言いたいのですが、どうすればいいのかわからない、というのが正直なところ。

こういう時、大抵探せば見つかるものです。既に実装している人がいるので確認します。
mrmurphy/reason-nextjs - reason-nextjs/src/Next.re

github.com/mrmurphy/reason-nextjs/blob/master/src/Next.re
//
// Hooks
//

module Router = {
  type t = {
    // Current route. That is the path of the page in /pages
    pathname: string,
    // The query string parsed to an object. Defaults to {}
    query: Js.Dict.t(string),
    // Actual path (including the query) shown in the browser
    asPath: string,
  };

  type pushOptions = {
    shallow: bool,
    getInitialProps: option(bool),
  };
  [@bs.send]
  external push:
    (t, ~url: string, ~asUrl: string=?, ~options: pushOptions=?, unit) => unit =
    "push";

  [@bs.send]
  external replace:
    (t, ~url: string, ~asUrl: string=?, ~options: pushOptions=?, unit) => unit =
    "replace";

  type popStateContext = {
    url: string,
    as_: string,
    options: pushOptions,
  };
  [@bs.send]
  external beforePopState: (t, popStateContext => bool) => unit =
    "beforePopState";

  ();
  // Events are not wrapped at the moment. Feel free to contribute!
};

[@bs.module "next/router"] external useRouter: unit => Router.t = "useRouter";

// Events are not wrapped at the moment. Feel free to contribute!とある通り、完全ではないようです。
このプロジェクトを使うのは手の内の一つだったのですが、最終更新日時が古かったのでNext.jsのサンプルに載せています。

useRouter関連のコードをNext.reの末尾に貼り付けます(いいのか?)
package.jsonを確認するとMITライセンスのようです。
MIT License 利用時の著作権表示箇所
ライセンス全文と著作権表示を記載しないといけないようです。
相手先のリポジトリにライセンス全文と著作権表示がないので、
改変元のコードの場所と作者の名前と今の年、デフォルトの内容をRouterの上に記載しておきます。

Next.re
/*
 https://github.com/mrmurphy/reason-nextjs/blob/master/src/Next.re
 Code copyright 2020 Murphy Randle
 Code released under the MIT license
 https://opensource.org/licenses/mit-license.php
 */

これでuseRouterが呼べるようになりました。

実装

前に追加した sample.reを変更し、フォームに入力してボタンを押したら遷移するようにしましょう。

sample.re
open Next;

[@react.component]
let make = () => {
  let (number, setNumber) = React.useState(() => "");
  let router = useRouter();
  let handleClick = _ => {
    let url = "/jumped?number=" ++ number;
    Router.push(router, ~url, ());
    ();
  };
  <div>
    <div> {ReasonReact.string({js|入力|js})} </div>
    <div>
      <input
        id="number"
        type_="text"
        name="number"
        value=number
        onChange={event => setNumber(ReactEvent.Form.target(event)##value)}
      />
    </div>
    <div>
      <button type_="button" onClick=handleClick>
        {ReasonReact.string({js|遷移|js})}
      </button>
    </div>
  </div>;
};

let default = make;

遷移先のページも作成します。

pages/jumped.re
[@react.component]
let make = (~number) => {
  <div> {ReasonReact.string(number)} </div>;
};

let default = make;

let getInitialProps = context =>
  Js.Promise.make((~resolve, ~reject as _) => {
    let number =
      switch (Js.Nullable.toOption(context##query##number)) {
      | None => "None"
      | Some(number) => number
      };
    resolve(. {"number": number});
  });

let inject:
  (
    Js.t('a) => React.element,
    {. "query": {. "number": Js.Nullable.t(string)}} =>
    Js.Promise.t({. "number": string})
  ) =>
  unit = [%bs.raw
  {| (cls, fn) => cls.getInitialProps = fn |}
];

inject(default, getInitialProps);

getInitialPropsはNext.jsの機能で、ページがレンダリングされる前に実行される内容を書き込みます。以下のフォーラムの質問を参考に実装しました。
Struggling converting from Js object to reasonml record in nextjs getInitialProps

APIを叩く

さぁ、APIを叩いてみましょう。ほとんどのサービスはWEB APIを叩くことで成り立っているので、ここが一番重要といっても過言ではありません。

今回は、livedoor 天気情報の第三者向け気象データ提供サービス「Weather Hacks」のお天気Webサービス(REST)を利用します。

bs-fetch, bs-jsonの追加

bs-fetch
bs-jsonは@glennsl/bs-jsonを利用します。

ちなみに、ReasonMLではPromiseの使用は非推奨のようです。async/awaitも未対応なので、XMLHttpRequestを使いましょうとのことです。今回はPromiseとbs-fetchを利用します。

$ yarn add bs-fetch @glennsl/bs-json

bsconfig.jsonも変更します。

bsconfig.json
{
  "name": "with-reasonml",
  "sources": [
    {
      "dir": "utils",
      "subdirs": true
    },
    {
      "dir": "components",
      "subdirs": true
    },
    {
      "dir": "pages",
      "subdirs": true
    },
    {
      "dir": "bindings",
      "subdirs": true
    }
  ],
  "bs-dependencies": ["reason-react", "bs-fetch", "@glennsl/bs-json"],
  "reason": { "react-jsx": 3 },
  "package-specs": { "module": "commonjs", "in-source": true },
  "suffix": ".bs.js",
  "bsc-flags": ["-bs-super-errors"],
  "refmt": 3
}

utilsフォルダを作成し、
bs-dependenciesに"bs-fetch", "@glennsl/bs-json"を追加します。

エラーが起きるのでpackage-specsのmoduleをes6からcommonjsに変更します。
ss_js_error.png

Api.reの作成

utils/Api.reを作成します。ページはルーティングの関係でsanake_case.reでしたが、他のファイルは基本的にPascalCase.reで書くのがルールのようです。他のcaseにしてもPascalCaseに変更されるとか。
ReasonReact を使ってみる - 名前空間の話

型の定義

JSONレスポンスをパースするために、レスポンスの型の情報を書きます。型を明示しない言語をずっと使ってきたので、なかなか慣れないですね。
[Weather Hacks - お天気Webサービス仕様]を見て、(http://weather.livedoor.com/weather_hacks/webservice)
実際のレスポンスを確認して書いていきましょう。

utils/Api.re(型部)
type linkNameData = {
  link: string,
  name: string,
};

type temperatureData = {
  celsius: string,
  fahrenheit: string,
};

type temperatureObj = {
  min: option(temperatureData),
  max: option(temperatureData),
};

type imageData = {
  width: int,
  link: option(string),
  url: string,
  title: string,
  height: int,
};

type forecastData = {
  dateLabel: string,
  telop: string,
  date: Js.Date.t,
  temperature: temperatureObj,
  image: imageData,
};

type locationData = {
  city: string,
  area: string,
  prefecture: string,
};

type copyrightData = {
  provider: array(linkNameData),
  link: string,
  title: string,
  image: imageData,
};

type descriptionData = {
  text: string,
  publicTime: Js.Date.t,
};

type weatherHacksData = {
  pinpointLocations: array(linkNameData),
  link: string,
  forecasts: array(forecastData),
  location: locationData,
  publicTime: Js.Date.t,
  copyright: copyrightData,
  title: string,
  description: descriptionData,
};

私がつまったところは、ネスト、配列、日付です。
ネスト -キーに対してオブジェクトが紐付くとき- は別のtypeに書きます。
配列のときはオブジェクト単位をネスト同様に書き、arrayでくくります。
日付はオブジェクトがJs.Dateで、型はJs.Date.tになるようです。

デコーダの定義

型に合わせてJSONをデコードする部分を書きます。

utils/Api.re(デコーダ部)
module Decode = {
  let linkName = (data: Js.Json.t) =>
    Json.Decode.{
      link: data |> field("link", string),
      name: data |> field("name", string),
    };
  let temperature' = (data: Js.Json.t) =>
    Json.Decode.{
      celsius: data |> field("celsius", string),
      fahrenheit: data |> field("fahrenheit", string),
    };
  let temperature = (data: Js.Json.t) =>
    Json.Decode.{
      min: data |> optional(field("min", temperature')),
      max: data |> optional(field("max", temperature')),
    };
  let image = (data: Js.Json.t) =>
    Json.Decode.{
      width: data |> field("width", int),
      link: data |> optional(field("link", string)),
      url: data |> field("url", string),
      title: data |> field("title", string),
      height: data |> field("height", int),
    };
  let forecast = (data: Js.Json.t) =>
    Json.Decode.{
      dateLabel: data |> field("dateLabel", string),
      telop: data |> field("telop", string),
      date: data |> field("date", string) |> Js.Date.fromString,
      temperature: data |> field("temperature", temperature),
      image: data |> field("image", image),
    };
  let location = (data: Js.Json.t) =>
    Json.Decode.{
      city: data |> field("city", string),
      area: data |> field("area", string),
      prefecture: data |> field("prefecture", string),
    };
  let copyright = (data: Js.Json.t) =>
    Json.Decode.{
      provider: data |> field("provider", Json.Decode.array(linkName)),
      link: data |> field("link", string),
      title: data |> field("title", string),
      image: data |> field("image", image),
    };
  let description = (data: Js.Json.t) =>
    Json.Decode.{
      text: data |> field("text", string),
      publicTime: data |> field("publicTime", string) |> Js.Date.fromString,
    };
  let weatherHacks = (data: Js.Json.t) =>
    Json.Decode.{
      pinpointLocations:
        data |> field("pinpointLocations", Json.Decode.array(linkName)),
      link: data |> field("link", string),
      forecasts: data |> field("forecasts", Json.Decode.array(forecast)),
      location: data |> field("location", location),
      publicTime: data |> field("publicTime", string) |> Js.Date.fromString,
      copyright: data |> field("copyright", copyright),
      title: data |> field("title", string),
      description: data |> field("description", description),
    };
};

デコーダは型毎につくっていきます。

utils/Api.re(デコーダ部・抜粋)
module Decode = {
  let weatherHacks = (data: Js.Json.t) =>
    Json.Decode.{
      link: data |> field("link", string),
    };
};

抜粋・簡易化した上の内容だと、jsonを

will_be_decoded.json
{
  "link": "will_be_decoded"
}

として、
(data: Js.Json.t) Js.Json型のdata(=responseのjson)を引数として、
Json.Decodeを実施
link: data |> field("link", string),引数dataの"link"キーの値をstringとしてデコードし返り値weatherHacksのlinkの値にするというイメージ。

Api kick

bs-fetchの実装例

utils/Api.re
let getWeather = locationId => {
  Js.Promise.(
    Fetch.fetch(
      "/api/forecast/webservice/json/v1?city=" ++ string_of_int(locationId),
    )
    |> then_(Fetch.Response.json)
    |> then_(obj =>
         obj
         |> Decode.weatherHacks
         |> (weatherHacks => Some(weatherHacks) |> resolve)
       )
    |> catch(_error => resolve(None))
  );
};

catch文がちょっと怪しい…ここはちょっと分かってないです。
page/sample.reで入力した数字(locationId)を文字列に変換して++で結合します。
返り値をoptionalにして、上手くいったらweatherHacksData型、エラーが起きたらNoneを返すつもりです。

jumped.re記述

Apiを呼び出す関数を定義し、初期読込時に実行します。

pages?jumped.re(コンポーネント部)
[@react.component]
let make = (~number) => {
  let getWeather = _ => {
    Api.getWeather(number)
    |> Js.Promise.then_((response: option(Api.weatherHacksData)) => {
         switch (response) {
         | Some((response: Api.weatherHacksData)) => Js.log(response)
         | None => alert("An error has occurred!!")
         };
         Js.Promise.resolve();
       })
    |> ignore;
    ();
  };

  React.useEffect0(() => {
    getWeather();
    None;
  });

  <div> {ReasonReact.string(string_of_int(number))} </div>;
};

useEffect0はHooksのメソッドで、読込時に呼ばれます。

React - 副作用フックの利用法

ヒント

React のライフサイクルに馴染みがある場合は、useEffect フックを componentDidMount と >componentDidUpdate と componentWillUnmount がまとまったものだと考えることができます。

getWeatherの中でApiを叩き、パターンマッチングで分岐してます。あればresponeをconsole.logで出力、なければメッセージをwindow.alertで表示します。

alertの利用については、bindings/Next.reに記述します。
Jsの機能なので(ですよね?)ファイルを分けたいところではあります。

bindings/Next.re
[@bs.val] external alert: string => unit = "alert";

これで一度動かしてみると、なんと!…CORSで失敗します。

Access to fetch at 'http://weather.livedoor.com/forecast/webservice/json/v1?city=200010' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

CORS対策

プロキシ設定を作成します。

Nextで特定のAPI リクエストをproxyする方法

$ yarn add express http-proxy-middleware
server.js
/* eslint-disable no-console */
const express = require('express');
const next = require('next');
const {createProxyMiddleware} = require('http-proxy-middleware');

const port = parseInt(process.env.PORT, 10) || 3000;
const env = process.env.NODE_ENV;
const dev = env !== 'production';
const app = next({
    dir: '.', // base directory where everything is, could move to src later
    dev,
});

const handle = app.getRequestHandler();

let server;
app
    .prepare()
    .then(() => {
        server = express();
        server.use(
            createProxyMiddleware('/api', {
                target: 'http://weather.livedoor.com',
                pathRewrite: {'^/api': ''},
                changeOrigin: true,
            }),
        );

        // Default catch-all handler to allow Next.js to handle all other routes
        server.all('*', (req, res) => handle(req, res));

        server.listen(port, (err) => {
            if (err) {
                throw err;
            }
            console.log(`> Ready on port ${port} [${env}]`);
        })
    })
    .catch((err) => {
        console.log('An error occurred, unable to start the server');
        console.log(err);
    });
createProxyMiddleware('/api', {
    target: 'http://weather.livedoor.com',
    pathRewrite: {'^/api': ''},
    changeOrigin: true,
}),

でプロキシを定義します。
/apiにアクセスするとhttp://weather.livedoor.comにアクセスするようにします。

utils/Api.re
let getWeather = locationId => {
  Js.Promise.(
    Fetch.fetch(
      "/api/forecast/webservice/json/v1?city=" ++ string_of_int(locationId),
    )
    |> then_(Fetch.Response.json)
    |> then_(obj =>
         obj
         |> Decode.weatherHacks
         |> (weatherHacks => Some(weatherHacks) |> resolve)
       )
    |> catch(_error => resolve(None))
  );
};

Apiのアドレス冒頭を/api/に置き換えます。

package.json(scripts)
"scripts": {
  "dev": "NODE_ENV=development concurrently \"bsb -clean-world -make-world -w\" \"node server.js\"",
  "dev:reason": "NODE_ENV=development bsb -clean-world -make-world -w",
  "dev:next": "NODE_ENV=development node server.js",
  "build": "NODE_ENV=production bsb -clean-world -make-world && next build",
  "start": "NODE_ENV=production node server.js"
},

nextコマンドでの起動からnode server.jsでの起動に切り替えます。

Ctrl+Cでwatchを止め、再度起動します。

$ yarn dev

http://localhost:3000/sampleにアクセスし、
200010を入力、ボタンをクリックすると…

ss_ajax.png

取得できました。無事パースされているようです。

表示

JSONを取得・パースできたということで、いよいよ表示します。

pages/jumped.re(make部)
[@react.component]
let make = (~number) => {
  let (weather, setWeather) = React.useState(() => None);
  let getWeather = _ => {
    Api.getWeather(number)
    |> Js.Promise.then_((response: option(Api.weatherHacksData)) => {
         switch (response) {
         | Some((response: Api.weatherHacksData)) =>
           Js.log(response);
           setWeather(_ => Some(response));
         | None => alert("None")
         };
         Js.Promise.resolve();
       })
    |> ignore;
    ();
  };

  React.useEffect0(() => {
    getWeather();
    None;
  });

  switch (weather) {
  | Some((weather: Api.weatherHacksData)) =>
    <div>
      <div> {ReasonReact.string(weather.title)} </div>
      <div> {ReasonReact.string(weather.description.text)} </div>
      {weather.forecasts
       ->Belt.Array.map(day => {
           <div key={day.dateLabel}>
             <div> {ReasonReact.string(day.dateLabel)} </div>
             <div> {ReasonReact.string(day.telop)} </div>
             <div>
               {ReasonReact.string(Js.Date.toDateString(day.date))}
             </div>
             <img src={day.image.url} />
           </div>
         })
       ->React.array}
    </div>
  | None => <div> {ReasonReact.string(string_of_int(number))} </div>
  };
};

make内を上の様に書きます。気温も表示しようと思ったのですが、最高/最低のoption値の扱いがわからなかった(なぜかOption(Option a')になっている?)ので、消してます。
ポイントはコンポーネントのループ処理でしょうか。
ReasonReact - A List of Simple Examples

ss_display.png

やったぜ。

補足

今回は遷移したかったので遷移しましたが、別にページ遷移する必要はない気がします。
ハッシュで同一ページで処理することも考えます。
また、useEffectで呼び出してますがNext.jsのSSR用のメソッドがあるので、それが使えるようにしたいですね。
今回、あんまりReactiveではないですね。Next.jsの恩恵を受けたい。

感想

今回の内容だけあれば大抵のアプリはなんとかなりそう。強いて言えばスタイルの適用でしょうか。
Post時のJSON EncodeはDecode同様にやれば行けると思います。
ReasonMLはJS/TSで書くこれはどうやるんや、と検索をかけてもほとんど引っかからないので厳しいです。あっても英語なので、理解にはどうしても日本語より時間がかかります。
また、bindingが書けないとNext.jsの全機能は活かせないです。
有力なライブラリはbsバインドが作成されていますが、自前で用意するのは型の理解と時間が必要なので、それなりの経験を要すると思います。
力のある方はbinding/Next.reを埋めていって欲しいです。

また、今回Qiitaの記事書いてみて、すごい時間がかかるなぁ、という気持ちになった。
ReasonML、苦しいですが、型安全で頑張れば完成度の高いコードになるはずなので、興味のある方は是非取り組んでみてください。

参考文献

Next.js
Reason
Example app using ReasonML & ReasonReact components
関数コンポーネントとクラスコンポーネント
String & Character - Reason
Next.js - Routing
Next.js - useRouter
mrmurphy/reason-nextjs - reason-nextjs/src/Next.re
MIT License 利用時の著作権表示箇所
Struggling converting from Js object to reasonml record in nextjs getInitialProps
第三者向け気象データ提供サービス「Weather Hacks」
bs-fetch
@glennsl/bs-json
ReasonReact を使ってみる - 名前空間の話
Weather Hacks - お天気Webサービス仕様
React - 副作用フックの利用法
Nextで特定のAPI リクエストをproxyする方法
ReasonReact - A List of Simple Examples
Decoding Nested JSON Objects in ReasonML with bs-json

アペンディクス

getInitialProps

getInitialPropsは非推奨になったみたいです…。
Next.js 9.3新API getStaticProps と getStaticPaths と getServerSideProps の概要解説
getInitialPropsを単純にgetServerSidePropsに置き換えるとエラーが出るので一旦getIntialPropsを使うことにします。

Error: page /jumped getServerSideProps can not be attached to a page's component and must be exported from the page. See more info here: https://err.sh/next.js/gssp-component-member

エラーの理由は https://err.sh/next.js/gssp-component-member にある通り、Next.js上で書き方に違いがあるから、とのようです。

ReasonML getServerSidePropsで検索すると、reasonml.orgのリポジトリにプルリクエストが発行されているのが見つかります。
ただ、We went a completely different route though. Instead of using getServerSideProps, we do the following:とある通り、getServerSidePropsは使わない方針になったのかもしれません。ただ、bindings/Next.reへの追記 - Improve GetServerSideProps bindingsはmergeされているようです。

bindings/Next.re

github.com/reason-association/reasonml.org/blob/master/bindings/Next.re
module GetServerSideProps = {
  module Req = {
    type t;
  };

  module Res = {
    type t;

    [@bs.send] external setHeader: (t, string, string) => unit = "setHeader";
    [@bs.send] external write: (t, string) => unit = "write";
    [@bs.send] external end_: t => unit = "end";
  };

  // See: https://github.com/zeit/next.js/blob/canary/packages/next/types/index.d.ts
  type context('props, 'params) = {
    params: Js.t('params),
    query: Js.Dict.t(string),
    req: Req.t,
    res: Res.t,
  };

  type t('props, 'params) =
    context('props, 'params) => Js.Promise.t({. "props": 'props});
};

module GetStaticProps = {
  // See: https://github.com/zeit/next.js/blob/canary/packages/next/types/index.d.ts
  type context('props, 'params) = {
    params: 'params,
    query: Js.Dict.t(string),
    req: Js.Nullable.t(Js.t('props)),
  };

  type t('props, 'params) =
    context('props, 'params) => Promise.t({. "props": 'props});
};

module GetStaticPaths = {
  // 'params: dynamic route params used in dynamic routing paths
  // Example: pages/[id].js would result in a 'params = { id: string }
  type path('params) = {params: 'params};

  type return('params) = {
    paths: array(path('params)),
    fallback: bool,
  };

  type t('params) = unit => Promise.t(return('params));
};

この方のプルリクエスト内の実装見て試してみたのですが、できなかったので保留します。

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?