もふもふ技術部

IT技術系mofmofメディア

RedwoodJSに入門してみた(第3回: CRUD作成 WEB編)

この記事について

この記事は、全5回の第3回です。

RedwoodJSに入門してみた(第1回: アプリ作成〜モデル作成)

RedwoodJSに入門してみた(第2回: CRUD作成 API編)

RedwoodJSに入門してみた(第3回: CRUD作成 WEB編)

RedwoodJSに入門してみた(第4回: dbAuthによる認証)

RedwoodJSに入門してみた(第5回: 実際に触ってみて感じたこと)

前回に引き続きRedwoodJSのScaffoldで作成されたファイルを見ていく。 今回はweb配下を中心に見ていく

Router

Redwoodには独自のRouterが内蔵されている。

ルーティングの仕組みはシンプルで、現在のURLが<Route>コンポーネントpathに一致すれば、そのpageレンダリングし、一致するものがなければnotfoundが指定されている<Route>pageレンダリングする。 web/src/Routes.tsxを見てみよう。

- import { Router, Route } from '@redwoodjs/router'
+ import { Set, Router, Route } from '@redwoodjs/router'

+ import ScaffoldLayout from 'src/layouts/ScaffoldLayout'

const Routes = () => {
  return (
    <Router>
+     <Set wrap={ScaffoldLayout} title="Posts" titleTo="posts" buttonLabel="New Post" buttonTo="newPost">
+       <Route path="/posts/new" page={PostNewPostPage} name="newPost" />
+       <Route path="/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
+       <Route path="/posts/{id:Int}" page={PostPostPage} name="post" />
+       <Route path="/posts" page={PostPostsPage} name="posts" />
+     </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

Scaffold実行後にはPost関連のルーティングが追加されている

Layout関連として、下記2つのファイルが作成されている

  1. web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx
  2. web/src/scaffold.css

さらにweb/src/App.tsxcssのインポート文が追加されているが、cssに関してはここでは割愛する

Route

まずは<Route>コンポーネントについて。

<Route>コンポーネントにはpath,page,nameを渡す必要がある。ここで渡したnameから名前付きルート関数の名前がつけられる。

例えば、下記のようなRouteを追加した場合、routeshome()という関数が追加され、routes.home()は文字列として/を返すようになる。

const Routes = () => {
  return (
    <Router>
      <Route path="/" page={HomePage} name="home" />
    </Router>
  );
};
import { Link, routes } from "@redwoodjs/router";

// <a href="/">が返される
const SomePage = () => <Link to={routes.home()} />;

Layout

次に、生成された<Layout>コンポーネントについて。

<ScaffoldLayout>web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsxに生成されているLayoutコンポーネントで、デフォルトではPost内で遷移するためのシンプルなヘッダーのようなものが作られている。

Layoutは下記コマンドで作成することもできる

yarn rw g layout foo

実行するとweb/src/layouts/FooLayout配下に3つのファイルが作成される

  • FooLayout.stories.tsx
  • FooLayout.test.tsx
  • FooLayout.tsx

Set

Layoutが渡されている<Set>コンポーネントについて。

<Set>コンポーネントにはLayoutを渡すことができる。例えば、Scaffoldで追加された下記の<Set>コンポーネントを見てみよう。

const Routes = () => {
  return (
    <Router>
      <Set
        wrap={ScaffoldLayout}
        title="Posts"
        titleTo="posts"
        buttonLabel="New Post"
        buttonTo="newPost"
      >
        <Route path="/posts/new" page={PostNewPostPage} name="newPost" />
        ...
      </Set>
    </Router>
  );
};

<Set>コンポーネントwrapScaffoldLayoutが渡されている

<Set>wrapに渡したコンポーネントは下記のように展開され、wrap以外のpropsはラッパーとして渡したコンポーネントに引数として渡される

const Routes = () => {
  return (
    <Router>
      <ScaffoldLayout
        title="Posts"
        titleTo="posts"
        buttonLabel="New Post"
        buttonTo="newPost"
      >
        <Route path="/posts/new" page={PostNewPostPage} name="newPost" />
        ...
      </ScaffoldLayout>
    </Router>
  );
};

だったら<Set>を使わずに<ScaffoldLayout>で囲めば良いのでは?という感じもするが、<Set>を使うことで、同じ<Set>内のページ間で再レンダリングが起こらなかったり、認証をチェックする機能があったりなど、いくつかのメリットがあるので基本的には<Set>を使うのが望ましい。

Page

次は<Route>コンポーネントpageで指定しているPageコンポーネントについて。

Scaffoldの実行で下記の4つが追加されている

  1. web/src/pages/Post/EditPostPage/EditPostPage.tsx
  2. web/src/pages/Post/NewPostPage/NewPostPage.tsx
  3. web/src/pages/Post/PostPage/PostPage.tsx
  4. web/src/pages/Post/PostsPage/PostsPage.tsx

ここで追加されたPageはそれぞれ対応するコンポーネントを返している

例えばEditPostPage.tsxは下記のようになっている

import EditPostCell from "src/components/Post/EditPostCell";

type PostPageProps = {
  id: string;
};

const EditPostPage = ({ id }: PostPageProps) => {
  return <EditPostCell id={id} />;
};

export default EditPostPage;

Postの編集に必要なidを受け取り、それをEditPostCellに渡している。

さて、改めてRoutes.tsxに追加された内容を見てみよう。下記のようになっているはずだ。

import { Set, Router, Route } from "@redwoodjs/router";

import ScaffoldLayout from "src/layouts/ScaffoldLayout";

const Routes = () => {
  return (
    <Router>
      <Set
        wrap={ScaffoldLayout}
        title="Posts"
        titleTo="posts"
        buttonLabel="New Post"
        buttonTo="newPost"
      >
        <Route path="/posts/new" page={PostNewPostPage} name="newPost" />
        <Route
          path="/posts/{id:Int}/edit"
          page={PostEditPostPage}
          name="editPost"
        />
        <Route path="/posts/{id:Int}" page={PostPostPage} name="post" />
        <Route path="/posts" page={PostPostsPage} name="posts" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  );
};

pageで指定されているPostNewPostPagePostEditPostPageはどこからもインポートされていないように見えるが、実はRedwoodはRoutes.tsx内のPageコンポーネントについては自動的にインポートしてくれるので、インポート文を書かなくても呼び出せる。

余談だが、Redwoodがインポートするとはいえ、エディタで怒られないのが不思議だなと思っていたら、pages配下のPageコンポーネントグローバル変数化されていた。

ちなみにPageの命名は、ディレクトリ名とファイル名を結合したものをもとにつけられているようだ(単純な結合ではないので注意)

web/src/pages/HomePage/HomePage.tsxだったらHomePageweb/src/pages/Post/NewPostPage/NewPostPage.tsxだったらPostNewPostPageのような感じ。

コマンドで作成する場合

PageもLayoutと同様にgenerateコマンドで作成できる

yarn rw g page home /のように、コマンドを実行するとweb/src/pages/HomePage配下に3つのファイルが作成される

  • HomePage.stories.tsx
  • HomePage.test.tsx
  • HomePage.tsx

Pageコンポーネントが作られるだけでなくRoutes.tsxにも下記のRouteが追加される <Route path="/" page={HomePage} name="home" />

これで、rootのパスにアクセスしたときに<HomePage>が表示されるようになる

yarn rw g page aboutのようにpathを指定しなかった場合は、下記のようにpage名をもとにしたpathが自動で設定される

<Route path="/about" page={AboutPage} name="about" />

Cell

CellはRedwood独自の仕様で、公式ドキュメントには、「データフェッチに対する宣言的なアプローチ」と書かれていた。

コンポーネントで呼び出していたデータフェッチライブラリによる状態管理をまとめて管理しちゃおう的なやつだと思われる。

従来だとそれぞれのコンポーネントでGraphQLの実行とライフサイクル管理をしていた。

const Posts() => {
  const { loading, error, data } = useQuery(GET_POSTS)

  if (loading) return 'Loading...'
  if (error) return `Error! ${error.message}`

  return (
    <ul>
      {data.posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

しかしRedwoodではCellがGraphQLの実行とライフサイクル管理を行うので、それぞれのコンポーネントはデータの状態を気にしなくてよい。

Scaffoldでは下記の3つのCellが追加されている。

  1. web/src/components/Post/PostCell/PostCell.tsx
  2. web/src/components/Post/PostsCell/PostsCell.tsx
  3. web/src/components/Post/EditPostCell/EditPostCell.tsx

PostCell.tsxについて見てみよう

import type { FindPostById } from "types/graphql";

import type { CellSuccessProps, CellFailureProps } from "@redwoodjs/web";

import Post from "src/components/Post/Post";

export const QUERY = gql`
  query FindPostById($id: String!) {
    post: post(id: $id) {
      id
      title
      body
      createdAt
    }
  }
`;

export const Loading = () => <div>Loading...</div>;

export const Empty = () => <div>Post not found</div>;

export const Failure = ({ error }: CellFailureProps) => (
  <div className="rw-cell-error">{error?.message}</div>
);

export const Success = ({ post }: CellSuccessProps<FindPostById>) => {
  return <Post post={post} />;
};

Cellは決められた名前で名前付きエクスポートする必要がある。

Cellが呼び出されるとRedwoodがQUERYを実行して、レスポンスが来るまでLoadingを表示、その後レスポンスに応じて、Empty, Failure, Successを返す。

Emptyコンポーネントをエクスポートしなかった場合はデフォルトで空のコンポーネントが定義される。

コンパイル時に何が行われているのやら、黒魔術感がすごいけど、Successコンポーネント内でデータを扱えば、loadingの状態を考慮しなくていいのはありがたい。

ちなみにRedwoodがCellを判断する基準は下記3点

  1. サフィックスにCellとつくファイル名であること
  2. QUERYが名前付きエクスポートされていること
  3. デフォルトエクスポートが定義されていないこと

基本的にコマンドで作成すれば、Cellの要件を満たさないということはなさそう。

超レアケースだが、Cell以外のファイルでサフィックスにCellをつけたくなったときに注意が必要。

コマンドで作成する場合

Cellもpageやlayoutと同様にgenerateコマンドで作成できる

yarn rw g cell userとするとweb/src/components/UserCell配下に4つのファイルが作成される

  • UserCell.mock.ts
  • UserCell.stories.tsx
  • UserCell.test.tsx
  • UserCell.tsx

今回はUser単体を取得するCellを作成したが、ユーザーの一覧などlistで取得したい場合は

yarn rw g cell usersのように複数形で指定するか、または複数形にできない名詞などはyarn rw g cell fish --listとすることでlistで取得するCellを作成できる

Component

その他、表示に関わる下記ファイルが生成されている

web/src/components/Post/NewPost/NewPost.tsx

web/src/components/Post/Post/Post.tsx

web/src/components/Post/PostForm/PostForm.tsx

web/src/components/Post/Posts/Posts.tsx

ちなみに、ここでのFormはHTMLのFormで作成されているがRedwoodには組み込みのFormライブラリもある。

コマンドで作成する場合

コンポーネントの作成はyarn rw g component Articleといったコマンドで、pagelayoutの作成と同様にweb/src/components/Article配下に3つのファイルが作成される

  • Article.stories.tsx
  • Article.test.tsx
  • Article.tsx

Package

Scaffoldでpackageも追加されている。

web/package.json

+ "humanize-string": "2.1.0",

fooBarとかfoo_barのような文字列をFoo barみたいに変換してくれるやつらしい。

こういうのもScaffoldで入れられるのは個人的にはあんまり嬉しくない。

web/src/lib/formatters.tsx内で定義されている関数で使用されていて、enumの値などをフォーマットして表示するのに使われる想定のようだが、日本語表示したい場合は必要なさそう。

補足

エディタ上のエラーについて

types/graphqlから型をインポートしているいくつかの箇所で、モジュール'types/graphql'またはそれに対応する型宣言が見つかりません。ts(2307)というエラーがエディタ上で出ていた。環境要因かもしれないが、対象のインポート文を一時的にコメントにして保存、その後コメントアウトして保存という動作をすると直った…。この場合のtypessrcディレクトリみたいに現在のworkspace配下のtypesディレクトリを見ているっぽいが、初回の読み込み時には対象のディレクトリを見つけられないのかもしれない。

自動生成されるファイル名について

Redwoodで作成されるファイル名は少し冗長な命名になっている

例えばScaffoldLayout配下にあるのに、ファイル名もScaffoldLayout.tsxになっているけど、これはlayouts/ScaffoldLayout/index.tsxでもいいのではないかと思った人もいるかも知れない。しかし、エディタで複数ファイルを開いたときにどのファイルを開いているか分かりやすいように、あえてファイル名の重複をなくしていたり、React Developer Tools使用時の視認性を考慮していたりで、そういうファイル名になっているらしい。

意外とディレクトリ名とファイル名に重複があっても気にならないし、逆に、index.tsが複数あってエディタの上部にはファイル名しか載ってないせいでindex.tsが複数あって、よく見たら違うファイルだったという経験もあるので、これは良いかもしれない。

次回

次回は認証を実装していく。