20211123のReactに関する記事は11件です。

React Nativeでのstyled-componentsの使用方法

はじめに React Nativeでのstyled-componentsの使い方についての記事があまり無かったので備忘録として書いておきます。何か気づき次第追加するかもしれません。 前提 TypeScriptの使用 yarnの使用 styled-componentsが書ける インストール styled-componentsをReact Nativeで使用するためには専用のパッケージも追加する必要があります。 yarn add styled-components yarn add -D @types/styled-components @types/styled-components-react-native 最小構成で動作確認 expo initで作成されるテンプレートを使ってstyled-componetsでHello,Worldしてみました。 App.tsx import { StatusBar } from "expo-status-bar"; import { Text } from "react-native"; import React from "react"; import styled from "styled-components/native"; export default function App() { return ( <SComponentView> <StatusBar style="auto" /> <Text>Hello,World</Text> </SComponentView> ); } const SComponentView = styled.View` flex: 1; background-color: #fff; align-items: center; justify-content: center; `; 注意点はstyledはreactnative専用のものをimportしないといけない点くらいです。ほかはだいたい一緒かなと思います。 おわりに 簡単ですね。ReactとReact Nativeで開発するならstyled-componentsがいいかなと思います。 参考文献 [1]:React Native で styled-components を使う
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「WSL2 + Docker」が遅いなら、速くすればいい

はじめに 自分はよく、「WSL2 + Docker + Laravel + React(or Vue)」環境で開発していて、動作が遅すぎて毎度イライラし、作業効率を大幅に下げていました。 シンプルにメモリのせいだと思い、メモリを増築・・・ しかし、それでも大差のない遅さ。 なんでだろうと思って色々調べたときに、対策したメモです。(Windows環境です。) Mac環境であれば、@ucan-labさんの https://qiita.com/ucan-lab/items/a88e2e5c2a79f2426163 をご参照ください。大変わかりやすいです。いつも大変お世話になっております・・・ ちなみに、ReactやVueとかの環境を作っている場合にもかなり効果的でした。 (npm run devとかするときですね。) 結論 「\\wsl$\Ubuntu-20.04\mnt\」配下に、プロジェクトを配置するのではなく、 「\\wsl$\Ubuntu-20.04\home\」配下に、プロジェクトを配置。 ※Ubuntuのバージョンは、良しなに・・・ つまり、 「¥¥wsl$\Ubuntu-20.04¥home¥」配下に、 Dockerfileとかdocker-componse.ymlがある場所で 「docker-compose up -d --build」とかをしてみる。 ということです。 内容 動作が遅くなっている理由として、 WindowsとLinuxのOSファイルシステム間でのファイル読み込みが遅くなっていることが原因です。 WSLがどういう構造で、どういうフォーマット形式で動作しているのかなどの確認をしたい場合には、 参考のリンクを参照ください。めちゃくちゃわかりやすいです、どちらも。 所感 ほんと、とんでもなく速くなっているので、なんでもっと早く気付かなかったのだろうと・・・ とにかく、「WSL2 + Docker + Laravel + React(or Vue)」などの環境で動作が遅いことを気にされている方は一読を。 参考 https://www.aska-ltd.jp/jp/blog/197 https://snyt45.com/posts/20210806/wsl2-multiple-linux-distribution/
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactエンジニアがReact Nativeを使ってみた

はじめに もともとReactを使用してweb開発をしていましたが、React Nativeを使用したモバイル開発に関わることになりました。Reactを使っていた人間がReact Nativeを勉強してみた所感を書きたいと思います。本記事はReact Native公式ドキュメント[1]を参考に書いていきます。 前提 Reactが使える TypeScriptが使える モバイルは初めて 環境構築と簡単な動作確認が知りたい人は前回の記事[1]で行っているのでそちらを参照してください。 React Nativeの必要性 現在モバイル端末での使用OSはAndroidOSとiOSで二極化しており、なおかつそれぞれでの使用プログラミング言語も異なります。昔はiOSではObjective-C、AndroidではJavaが使用されていました。しかし、最近ではiOSではSwift、AndroidではKotlinという言語が使われており、2つのOS合わせると最大4つのコードが存在する可能性があるわけです。もちろんこれは開発効率の観点から見て良くなく、これを解決するためにReact Nativeなどのクロスプラットフォームで開発出来るフレームワークが開発されてきたわけです。 React Nativeでないとだめなのか 近年よく比べられるフレームワークとしてFlutterがあります。よくある議論として「Flutter vs React Native」のようなものがありますが、開発コストという点で見ればReact Nativeの方がFlutterよりも明らかに効率的です。FlutterではDartという言語を新しく覚える必要があり、学習コストは高いです。それに対してReact NativeはReactの知識を利用して開発出来るので、学習コストは比較的低いです。パフォーマンス的にはFlutterの方が良いのかもしれませんが、そこまでパフォーマンスを求めないのであれば手軽に作成できるReact Nativeの方が良いでしょう。ただそのまま使えるわけではなく、多少は覚えることがあるので注意です。 ここからはReact Nativeの概念について話していきたいと思います。 React Nativeのコンポーネント React Native公式ではコンポーネントを何種類かに分けて呼んでいます。 Native Components それぞれのOSでのViewを担当する部分がバックアップしているコンポーネント。 Core Components React Nativeですぐに使えるNative Componentsのセットコンポーネント。 community-contributed components React Nativeコミュニティが開発する各OS独自のNative Component。 各OSの特定の部分に対応するものがReact Nativeコンポーネントであり、それをまとめたものがCore Componentsであると。恐らく僕たちが使うのは基本的にCore Componentsになるということでしょう。それで足りない場合はcommunity-contributed componentsなどにないか探してみるというような感じなのでしょうか。 公式のベン図的な画像を載せておきます。 [1]より引用 あくまでReact Native ComponentsはReact Componentsの部分集合であるということなので、この図からもReact Componentsを理解していれば問題なさそうということが分かりますね。 React Nativeの文法 さてここからが本題です。ReactにはないReact Native特有の部分を見ていきたいと思います。 App.tsxを理解する まずはexpo initコマンドで作成されたテンプレートのApp.tsxの解読をしていきましょう。文章はHello,Worldに変更してあります。 App.tsx import { StatusBar } from "expo-status-bar"; import React from "react"; import { StyleSheet, Text, View } from "react-native"; export default function App() { return ( <View style={styles.container}> <Text>Hello,World</Text> <StatusBar style="auto" /> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#fff", alignItems: "center", justifyContent: "center", }, }); まず、ReactをimportしているようですがこれはReactと違って必要でした。内部的に使っているんでしょうか。StatusBarというやつはなんだろうと調べると、モバイル上部に表示される電源残量や電波状況を表すアイコンが並んでいる部分をステータスバーというようです。ViewとTextはReact Nativeのコアコンポーネントで、詳しくは後ほど説明します。Viewに対してstyleを適用しているようですが、stylesというオブジェクトのプロパティとしてcontainerを設定し、スタイルを記述しています。スタイルについても後ほど詳しく記述しますが、ここではキャメルケースでの書き方でないといけないようで、Reactでのインラインスタイルによるスタイリングと同じですね。 ざっと見ていきましたが、大枠はReactと変わらないようなので、あとは各コンポーネントを覚えていくだけかなと思います。 コンポーネントのprops React Nativeのリファレンスを見ると、propsがiOSとAndroidOSで異なる物が多いようです。また、propsの数自体相当多く、これを全て覚えるのはかなり大変だと思います。この記事では各OS限定のpropsは基本的に扱わず、使いそうなpropsがあれば紹介するという感じで紹介していきますので、詳しく知りたい方は公式のリファレンス[3]を参考にしてください。 View React Nativeにおける一番基本的なコンポーネントです。divタグのようなものだと思って貰えばいいと思います(厳密には少し違う)。 SafeAreaView(iOS) iOSデバイスではノッチや角丸などの物理な違いによって安全にレンダリング出来る範囲が異なります。このコンポーネントでは安全な範囲内でコンテンツをレンダリングすることが出来ます。 Text 文字を書く場合は基本的にこれを使います。Viewに直接書いても反映されません。 ネスト出来る AndroidとiOSでは文字列の範囲に特定の書式をつけることが出来、この実現のためにTextはネストすることが出来ます。ネストしたテキストだけをスタイリングすることで、任意の範囲に書式をつけることが出来ます。 ・レイアウトが特殊 また、Textはレイアウトに関して特殊です。Test内の要素は長方形ではなくなる場合があり、行の終わりを見て折り返します。 ・スタイルの継承が制限されている テキストのスタイルはTextによってしか継承されません。つまりViewでテキストに関するスタイルをしても繁栄されません。特定のスタイルのTextを使いまわしたい場合は既存のTextを拡張したコンポーネントを定義することが公式では進められています。Text内では継承されるため、ネストしたTextでは親のスタイルが適用されます。 Image 画像を利用する際に使います。propsのsourceに参照を渡すことで画像が表示できます。下記に公式の例を載せます。 import React from 'react'; import { View, Image, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ container: { paddingTop: 50, }, stretch: { width: 50, height: 200, resizeMode: 'stretch', }, }); const DisplayAnImageWithStyle = () => { return ( <View style={styles.container}> <Image style={styles.stretch} source={require('@expo/snack-static/react-native-logo.png')} /> </View> ); } export default DisplayAnImageWithStyle; [3]より引用。 TextInput 入力で使用します。全部説明すると長くなるので例を下記に示します。 import React from "react"; import { SafeAreaView, StyleSheet, TextInput } from "react-native"; const UselessTextInput = () => { const [text, onChangeText] = React.useState("Useless Text"); const [number, onChangeNumber] = React.useState(null); return ( <SafeAreaView> <TextInput style={styles.input} onChangeText={onChangeText} value={text} /> <TextInput style={styles.input} onChangeText={onChangeNumber} value={number} placeholder="useless placeholder" keyboardType="numeric" /> </SafeAreaView> ); }; const styles = StyleSheet.create({ input: { height: 40, margin: 12, borderWidth: 1, padding: 10, }, }); export default UselessTextInput; [3]より引用 一番ある使い方はonChangeTextにstateのセッターを入れておいて、入力を反映しつつstateで保持みたいな感じだと思います。気になるのはTypeScriptで使う際にnumberとstringどうやって分けているんだろうというとこですね。時間のある時に検証したら更新します。 ・下にボーダーがある TextInputはデフォルトでビューの下部にボーダーを持ちます。これはシステムが提供する背景画像によってパディングが設定されており、変更することは出来ません。この問題を回避するには、高さを明示的に設定しないようにして、システムが適切な位置にボーダーを表示するようにするか、underlineColorAndroidをtransparentに設定してボーダーを表示しないようにすることが必要なようです。詳しくは公式リファレンスを見てください。 ScrollView リファレンスを直訳したんですが、少し意味がわからないところがあったので分かりにくいかもしれません。 スクロールしたい場合に使います。React NativeではViewを使って表示する場合、表示画面領域より大きいものをレンダリングしようとしても自動的にスクロールするようにはならないようです。スクロールしないことは少ないと思うので基本的にはこれを使うことになるんでしょうか。 ・全ての親Viewが高さ制限されている必要がある リファレンスではboundedと書かれていたのですが、この訳で合っているのかあやしいです。高さを制限する方法は2つありViewの高さを直接設定すること(非推奨)と全ての親Viewに高さを設定することです。{flex:1}を忘れるとエラーが出るとリファレンスには書いてあるのですが、よく分かりません。下記に例を載せておきます。 import React from 'react'; import { StyleSheet, Text, SafeAreaView, ScrollView, StatusBar } from 'react-native'; const App = () => { return ( <SafeAreaView style={styles.container}> <ScrollView style={styles.scrollView}> <Text style={styles.text}> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. </Text> </ScrollView> </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, paddingTop: StatusBar.currentHeight, }, scrollView: { backgroundColor: 'pink', marginHorizontal: 20, }, text: { fontSize: 42, }, }); export default App [3]より引用。 showsVerticalScrollIndicator このpropsをfalseにすることでスクロールバーを表示しないように出来ます。 horizontal このpropsをtrueにすることで横スクロールにも対応できます。 FlatlListとの違い ScrollViewでは全ての子コンポーネントを一度にレンダリングするため、パフォーマンス上の問題があります。FlatListではアイテムが表示されようとしているときに遅延的にレンダリングし、画面外に大きくスクロールするアイテムを削除することでメモリや処理時間の節約が出来ます。FlatListはほかにもアイテム間の距離、複数列、無限スクロールローディングなどの機能をサポートしているので便利です。 ここまで書いていて思いましたが、もしかして割とScrollViewって要らない子?状況に応じて使い分けるんですかね。 StyleSheet ここまででも使用してきていますが、cssのスタイルを使用するために利用しています。特に言うことはないのですが、React NativeはTailwind CSSとは相性が良くなさそうだなーと思います。調べた感じライブラリなどを使えばいけるようですが、vscodeのインテリセンスなどの関係上どうなのかなーという感じです。 逆にCSS in JSは特に変化なく使えそうだなと思います。webとほとんど変わらず記述出来るので、開発コストを削減するという観点から見たら、React NativeではCSS in JSを用いるのがいいんじゃないかなと思います。僕はstyled-componentsを用いて書いているので苦労することはなさそうです。 Button ボタンに相当するコンポーネントです。見たほうが早いと思うので下記に例を載せます。 <Button title="Press me" disabled onPress={() => Alert.alert('Cannot press this one')} /> [3]より引用。 propsとしてonPressとtitleは必須のようです。titleでボタンの表示を決めるようですが、childrenでは無理ということでしょうか。Alert.alertでwindow.alertのようなことが出来るようです。またこの例ではdisabledにしているので利用できないような見た目になります。動的に決定したい場合はdisabledのところにboolean型の変数を入れれば可能でしょう。 Switch このSwitchはswitch文のことではなく、切り替えるためのボタンのことです(名前あるのかな)。リファレンスのダークモード切り替えの部分によくあるやつです。下記に例を示します。 <Switch trackColor={{ false: "#767577", true: "#81b0ff" }} thumbColor={isEnabled ? "#f5dd4b" : "#f4f3f4"} ios_backgroundColor="#3e3e3e" onValueChange={toggleSwitch} value={isEnabled} /> [3]より引用 タップすることでboolean型変数がトグルし、valueの値によって色々変わるという感じです。 TouchableOpacity 押されるとラップされたViewの不透明度が減少し、薄暗くなります。 <TouchableOpacity style={styles.button} onPress={onPress} > <Text>Press Here</Text> </TouchableOpacity> [3]より引用 FlatList 先程も説明しましたが、リスト表示をする際に利用します。 data 表示したい配列データ。 renderItem dataを使ってどのようなものを表示するかを決める関数 extraData 追加データ。下の例では再レンダリングするためにstateを渡しています。 keyExtractor デフォルトのkeyプロパティの代わりに使用するidの指定。 ・コンテンツがレンダリング領域の外にいったら内部状態は保持されない FlatListの仕様上レンダリングの外のコンテンツの内部状態は保存されません。そのためstateの更新時とレンダリングについて気をつける必要があります ・コンテンツは画面外で非同期にレンダリングされる この特徴によりコンテンツ外のレンダリングよりも早くスクロールすると、一時的に空白のコンテンツが表示される可能性があります。 import React, { useState } from "react"; import { FlatList, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity } from "react-native"; const DATA = [ { id: "bd7acbea-c1b1-46c2-aed5-3ad53abb28ba", title: "First Item", }, { id: "3ac68afc-c605-48d3-a4f8-fbd91aa97f63", title: "Second Item", }, { id: "58694a0f-3da1-471f-bd96-145571e29d72", title: "Third Item", }, ]; const Item = ({ item, onPress, backgroundColor, textColor }) => ( <TouchableOpacity onPress={onPress} style={[styles.item, backgroundColor]}> <Text style={[styles.title, textColor]}>{item.title}</Text> </TouchableOpacity> ); const App = () => { const [selectedId, setSelectedId] = useState(null); const renderItem = ({ item }) => { const backgroundColor = item.id === selectedId ? "#6e3b6e" : "#f9c2ff"; const color = item.id === selectedId ? 'white' : 'black'; return ( <Item item={item} onPress={() => setSelectedId(item.id)} backgroundColor={{ backgroundColor }} textColor={{ color }} /> ); }; return ( <SafeAreaView style={styles.container}> <FlatList data={DATA} renderItem={renderItem} keyExtractor={(item) => item.id} extraData={selectedId} /> </SafeAreaView> ); }; ・・・ [3]より引用 map関数によるレンダリングの代わりなのかなーとおもっています。 Section List FLatListに見出しがついたような感じのものです。ほとんど同じなので使い方は下のサンプルプログラムを見てください。 import React from "react"; import { StyleSheet, Text, View, SafeAreaView, SectionList, StatusBar } from "react-native"; const DATA = [ { title: "Main dishes", data: ["Pizza", "Burger", "Risotto"] }, { title: "Sides", data: ["French Fries", "Onion Rings", "Fried Shrimps"] }, { title: "Drinks", data: ["Water", "Coke", "Beer"] }, { title: "Desserts", data: ["Cheese Cake", "Ice Cream"] } ]; const Item = ({ title }) => ( <View style={styles.item}> <Text style={styles.title}>{title}</Text> </View> ); const App = () => ( <SafeAreaView style={styles.container}> <SectionList sections={DATA} keyExtractor={(item, index) => item + index} renderItem={({ item }) => <Item title={item} />} renderSectionHeader={({ section: { title } }) => ( <Text style={styles.header}>{title}</Text> )} /> </SafeAreaView> ); ・・・ [3]より引用。 おわりに ざっと概念と文法を見てきましたが、理解すれば簡単に使えそうです。ただ、想像していたよりは覚えることがあったのであとは実際に開発してから使用感を確かめていきたいと思います。 参考文献 [1]:Introduction [2]:Ubuntu20.04LTSでReact NativeとExpoを試す [3]:Core Components and APIs
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React + debounceでテキスト入力後に処理実行(Material UI + Autocomplete)

はじめに 以前アップした内容は、nodeにデフォルトである(?)lodashのdebounceを使ったら簡単でしたという話です。 Reactの関数コンポーネント型でテキスト入力後に自前でタイマー処理を実装して処理を実行していたが、そんなことせずにlodash使った方が良いんだろうねというお話です。 環境 material ui React ※投稿時の最新 パッケージ追加 yarn add @mui/material @emotion/react @emotion/styled yarn add @mui/material @mui/styled-engine-sc styled-components コード import { useState, useEffect } from "react"; import { TextField, Autocomplete } from "@mui/material"; import { debounce } from "lodash"; const AutocompleteSample = () => { // 前回のキーワード const [beforeString, setBeforeString] = useState(""); // 表示候補(本来はreduxのuseSelectorなんかでデータを管理する) const [resultOptions, setResultOptions] = useState([]); // 変更が止まった後、指定時間(ミリ秒)後処理を行う const term = 500; const debounceSearchHandler = debounce( (changeValue) => searchMethod(changeValue), term ); // 検索処理 const searchMethod = (changeValue) => { // 内容が同じ場合は処理しない(エンターの決定対策) if (changeValue.length > 0 && beforeString !== changeValue) { // 処理時のテキスト内容を保持 setBeforeString(changeValue); // 実際は、FetchでAutocompleteのoptionsの内容を取得する処理を記載 console.log("search function", changeValue); } }; // 実際は、検索処理を行い、データを受信したときに実行する処理を記載 useEffect(() => { if (resultOptions.length > 0) { console.log("result function", resultOptions); } }, [resultOptions]); return ( <Autocomplete options={resultOptions} // 候補 onInputChange={(e, changeValue) => debounceSearchHandler(changeValue)} // テキスト内容変更時 renderInput={(params) => <TextField {...params} />} /> ); }; export default AutocompleteSample; 終わりに 素のJsに頼り勝ち、、何とかしないとね、、
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactプロジェクトにmswを導入し、モックAPIとして利用する

はじめに フロントエンド開発を進める中で、機能実装を進めたいけど、バックエンドの実装がまだ完了していない、というケースは結構あると思います。 APIの設計さえ決まっていれば、モックを用意して、機能開発を進めることが出来ます。その際に、msw(Mock Service Worker)が結構便利だったので、備忘録として残しておきます。 mswって何? mswは、ブラウザ側からのAPIリクエストをService Workerがインターセプトして、任意のmockデータを返すためのライブラリです。特徴の一つとして、ブラウザでもnode.js上でも動作する点が挙げられるかと思います。これは、テストコードやStorybook等を利用してるプロジェクトでも活かせる利点となります。 導入は簡単です。 詳細を知りたい方は上記公式サイトを参照してみてください。 Reactプロジェクト作成〜msw導入 sampleAppの雛形 $ npx create-react-app msw_sample --template typescript $ cd msw_sample $ npm install msw --save-dev モックの定義 公式サイトを読むと、モック定義の管理に厳密なルールはないようですが、関連モジュールを単一のディレクトリにまとめることが推奨されています。 mocksディレクトリの作成し、handlerを記述するファイルを作成 $ mkdir src/mocks $ touch src/mocks/handlers.ts 次にインターセプトする際のハンドラーの処理を実装していきます。 REST APIリクエストを処理するには、HTTPメソッドとパス、レスポンスを指定する必要があります。 以下に例を示します。 handlers.ts import { rest } from "msw"; import { User } from "../common/types/User"; export const handlers = [ rest.post("/login", (_, res, ctx) => { sessionStorage.setItem("is-authenticated", "true"); return res(ctx.status(200)); }), rest.get("/user", (_, res, ctx) => { const isAuthenticated = sessionStorage.getItem("is-authenticated"); if (!isAuthenticated) { return res( ctx.status(403), ctx.json({ errorMessage: "Not authorized", }) ); } return res( ctx.status(200), ctx.json({ username: "Taro", age: 30, role: "admin", } as User) ); }), ]; リクエストに対してレスポンスを返すようにするには、リゾルバー関数を使用してモックされたレスポンスを指定する必要があります。 リゾルバー関数は、以下の引数を指定できます。 req 今回は特に記述していないですが、リクエストの情報が入ってくるので、ここの値に応じて、レスポンスを変更することも可能。 res レスポンスを作成するための機能ユーティリティ。 ctx モックされた応答のステータスコード、ヘッダー、本文などを設定するのに役立つ関数のグループ。 Service Workerのコードを生成 mswはService Workerを使用して、APIリクエストをインターセプトします。そのService Workerのコードをプロジェクトの公開ディレクトリに追加するコマンドが用意されています。Reactは、./publicになるので、そちらに追加しましょう。 mockServiceWorkerをpublicディレクトリに生成する $ npx msw init public/ --save 生成されたmockServiceWorker.jsには、Service Workerでのイベント処理が書かれています。 Service Workerを起動するファイルを作成 続いて、Service Workerを生成して起動するために必要なファイルを作成します。 $ touch src/mocks/browser.ts 生成したファイルにはリクエストハンドラーを渡してワーカーインスタンスを作成する処理を書きます。 src/mocks/browser.ts import { setupWorker } from "msw"; import { handlers } from "./handlers"; export const worker = setupWorker(...handlers); エントリポイントにworkerをimportする mswを使用するためには、アプリケーションのエントリポイント( index.tsx )にService Workerのスタート処理をインポートする必要があります。開発環境でのみ、src/mocks/browser.tsファイルをインポートします。(本番環境でmockを使うことはまず無いでしょう) src/index.tsx import React from "react"; import ReactDOM from "react-dom"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; if (process.env.NODE_ENV === "development") { const { worker } = require("./mocks/browser"); worker.start(); } ReactDOM.render( <BrowserRouter> <App /> </BrowserRouter>, document.getElementById("root") ); これで開発時にプロジェクトを立ち上げると、モックも動作するようになりました! 実際に使ってみる アプリケーション起動 $ npm run start consoleに以下の表示がされていれば、mswは正常に動作しています。 あくまで簡易的なものですが、モックをリクエストしてるコンポーネントの実装は以下の通りです。(ログインボタン押下 → Topページへ遷移) src/pages/Login/index.tsx import React from "react"; import { useNavigate } from "react-router-dom"; //React-Router ![Something went wrong]() V6 export const Login = () => { const navigate = useNavigate(); const USER_PAGE = "/user"; const login = () => { fetch("http://localhost:3000/login", { method: "POST" }).then(() => navigate(USER_PAGE) ); }; return <button onClick={login}>login</button>; }; export default Login; src/pages/Home/index.tsx import React, { useEffect, useState } from "react"; import { UserCard } from "../../common/components/UserCard"; import { User } from "../../common/types/User"; export const Home: React.FC = () => { const [user, setUser] = useState<User>({ username: "", age: null, role: "" }); useEffect(() => { const fetchUser = async () => { await fetch("http://localhost:3000/user") .then((res) => { return res.json(); }) .then((res) => { setUser(res); }) .catch((res) => { console.log(res.errorMessage); }); }; fetchUser(); }, []); return ( <> <h1>Hello</h1> <UserCard user={user} /> </> ); }; export default Home; src/common/components/UserCard/index.tsx import React from "react"; import { User } from "../../types/User"; type Props = { user: User; }; export const UserCard: React.FC<Props> = (props: Props) => { const { user } = props; return ( <> <p> 氏名: {user.username}/{user.age}歳 </p> <p>役割: {user.role}</p> </> ); }; DevToolのNewworkタブを見てみましょう。 インターセプトされたリクエストが確認できるでしょうか? 最後に 以上、mswの利用例でした。 以前、Pythonでモックサーバーを作っていた事もあったけど、別途、モック用のサーバーを立てるより、手軽に利用できて良い感じです! 以下サンプルコードです。 テストコードや、Storybookでも試してみようかなと思うので、その時は別途、記事にしようと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

APIの完成を待たずに開発に着手したい!!mswを導入し、モックAPIとして利用する(React)

はじめに フロントエンド開発を進める中で、機能実装を進めたいけど、バックエンドの実装がまだ完了していない、というケースは結構あると思います。 APIの設計さえ決まっていれば、モックを用意して、機能開発を進めることが出来ます。その際に、msw(Mock Service Worker)が結構便利だったので、備忘録として残しておきます。 開発してると以下のケースって割と遭遇すると思います。 ・早めにフロントエンド開発に着手したいけど、APIがまだ完成してない。 ・テストで使用するモックデータが必要 この場合、mswは結構便利かなと思います。 ※サンプルコードはこちら(参考程度に) mswって何? mswは、ブラウザ側からのAPIリクエストをService Workerがインターセプトして、任意のmockデータを返すためのライブラリです。特徴の一つとして、ブラウザでもnode.js上でも動作する点が挙げられるかと思います。これは、テストコードやStorybook等を利用してるプロジェクトでも活かせる利点となります。 導入は簡単です。 詳細を知りたい方は上記公式サイトを参照してみてください。 Reactプロジェクト作成〜msw導入 sampleAppの雛形 $ npx create-react-app msw_sample --template typescript $ cd msw_sample $ npm install msw --save-dev モックの定義 公式サイトを読むと、モック定義の管理に厳密なルールはないようですが、関連モジュールを単一のディレクトリにまとめることが推奨されています。 mocksディレクトリの作成し、handlerを記述するファイルを作成 $ mkdir src/mocks $ touch src/mocks/handlers.ts 次にインターセプトする際のハンドラーの処理を実装していきます。 REST APIリクエストを処理するには、HTTPメソッドとパス、レスポンスを指定する必要があります。 以下に例を示します。 handlers.ts import { rest } from "msw"; import { User } from "../common/types/User"; export const handlers = [ rest.post("/login", (_, res, ctx) => { sessionStorage.setItem("is-authenticated", "true"); return res(ctx.status(200)); }), rest.get("/user", (_, res, ctx) => { const isAuthenticated = sessionStorage.getItem("is-authenticated"); if (!isAuthenticated) { return res( ctx.status(403), ctx.json({ errorMessage: "Not authorized", }) ); } return res( ctx.status(200), ctx.json({ username: "Taro", age: 30, role: "admin", } as User) ); }), ]; リクエストに対してレスポンスを返すようにするには、リゾルバー関数を使用してモックされたレスポンスを指定する必要があります。 リゾルバー関数は、以下の引数を指定できます。 req 今回は特に記述していないですが、リクエストの情報が入ってくるので、ここの値に応じて、レスポンスを変更することも可能。 res レスポンスを作成するための機能ユーティリティ。 ctx モックされた応答のステータスコード、ヘッダー、本文などを設定するのに役立つ関数のグループ。 Service Workerのコードを生成 mswはService Workerを使用して、APIリクエストをインターセプトします。そのService Workerのコードをプロジェクトの公開ディレクトリに追加するコマンドが用意されています。Reactは、./publicになるので、そちらに追加しましょう。 mockServiceWorkerをpublicディレクトリに生成する $ npx msw init public/ --save 生成されたmockServiceWorker.jsには、Service Workerでのイベント処理が書かれています。 Service Workerを起動するファイルを作成 続いて、Service Workerを生成して起動するために必要なファイルを作成します。 $ touch src/mocks/browser.ts 生成したファイルにはリクエストハンドラーを渡してワーカーインスタンスを作成する処理を書きます。 src/mocks/browser.ts import { setupWorker } from "msw"; import { handlers } from "./handlers"; export const worker = setupWorker(...handlers); エントリポイントにworkerをimportする mswを使用するためには、アプリケーションのエントリポイント( index.tsx )にService Workerのスタート処理をインポートする必要があります。開発環境でのみ、src/mocks/browser.tsファイルをインポートします。(本番環境でmockを使うことはまず無いでしょう) src/index.tsx import React from "react"; import ReactDOM from "react-dom"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; if (process.env.NODE_ENV === "development") { const { worker } = require("./mocks/browser"); worker.start(); } ReactDOM.render( <BrowserRouter> <App /> </BrowserRouter>, document.getElementById("root") ); これで開発時にプロジェクトを立ち上げると、モックも動作するようになりました! 実際に使ってみる アプリケーション起動 $ npm run start consoleに以下の表示がされていれば、mswは正常に動作しています。 あくまで簡易的なものですが、モックをリクエストしてるコンポーネントの実装は以下の通りです。(ログインボタン押下 → Topページへ遷移) src/pages/Login/index.tsx import React from "react"; import { useNavigate } from "react-router-dom"; //React-Router ![Something went wrong]() V6 export const Login = () => { const navigate = useNavigate(); const USER_PAGE = "/user"; const login = () => { fetch("http://localhost:3000/login", { method: "POST" }).then(() => navigate(USER_PAGE) ); }; return <button onClick={login}>login</button>; }; export default Login; src/pages/Home/index.tsx import React, { useEffect, useState } from "react"; import { UserCard } from "../../common/components/UserCard"; import { User } from "../../common/types/User"; export const Home: React.FC = () => { const [user, setUser] = useState<User>({ username: "", age: null, role: "" }); useEffect(() => { const fetchUser = async () => { await fetch("http://localhost:3000/user") .then((res) => { return res.json(); }) .then((res) => { setUser(res); }) .catch((res) => { console.log(res.errorMessage); }); }; fetchUser(); }, []); return ( <> <h1>Hello</h1> <UserCard user={user} /> </> ); }; export default Home; src/common/components/UserCard/index.tsx import React from "react"; import { User } from "../../types/User"; type Props = { user: User; }; export const UserCard: React.FC<Props> = (props: Props) => { const { user } = props; return ( <> <p> 氏名: {user.username}/{user.age}歳 </p> <p>役割: {user.role}</p> </> ); }; DevToolのNewworkタブを見てみましょう。 インターセプトされたリクエストが確認できるでしょうか? 最後に 以上、mswの利用例でした。 以前、Pythonでモックサーバーを作っていた事もあったけど、別途、モック用のサーバーを立てるより、手軽に利用できて良い感じです!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

APIの完成を待たずに開発に着手したい!!mswを導入し、お手軽にモックAPIを作る(React)

はじめに フロントエンド開発を進める中で、機能実装を進めたいけど、バックエンドの実装がまだ完了していない、というケースは結構あると思います。 APIの設計さえ決まっていれば、モックを用意して、機能開発を進めることが出来ます。その際に、msw(Mock Service Worker)が結構便利だったので、備忘録として残しておきます。 開発してると以下のケースって割と遭遇すると思います。 ・早めにフロントエンド開発に着手したいけど、APIがまだ完成してない。 ・テストで使用するモックデータが必要 この場合、mswは結構便利かなと思います。 ※サンプルコードはこちら(参考程度に) mswって何? mswは、ブラウザ側からのAPIリクエストをService Workerがインターセプトして、任意のmockデータを返すためのライブラリです。特徴の一つとして、ブラウザでもnode.js上でも動作する点が挙げられるかと思います。これは、テストコードやStorybook等を利用してるプロジェクトでも活かせる利点となります。 導入は簡単です。 詳細を知りたい方は上記公式サイトを参照してみてください。 Reactプロジェクト作成〜msw導入 sampleAppの雛形 $ npx create-react-app msw_sample --template typescript $ cd msw_sample $ npm install msw --save-dev モックの定義 公式サイトを読むと、モック定義の管理に厳密なルールはないようですが、関連モジュールを単一のディレクトリにまとめることが推奨されています。 mocksディレクトリの作成し、handlerを記述するファイルを作成 $ mkdir src/mocks $ touch src/mocks/handlers.ts 次にインターセプトする際のハンドラーの処理を実装していきます。 REST APIリクエストを処理するには、HTTPメソッドとパス、レスポンスを指定する必要があります。 以下に例を示します。 handlers.ts import { rest } from "msw"; import { User } from "../common/types/User"; export const handlers = [ rest.post("/login", (_, res, ctx) => { sessionStorage.setItem("is-authenticated", "true"); return res(ctx.status(200)); }), rest.get("/user", (_, res, ctx) => { const isAuthenticated = sessionStorage.getItem("is-authenticated"); if (!isAuthenticated) { return res( ctx.status(403), ctx.json({ errorMessage: "Not authorized", }) ); } return res( ctx.status(200), ctx.json({ username: "Taro", age: 30, role: "admin", } as User) ); }), ]; リクエストに対してレスポンスを返すようにするには、リゾルバー関数を使用してモックされたレスポンスを指定する必要があります。 リゾルバー関数は、以下の引数を指定できます。 req 今回は特に記述していないですが、リクエストの情報が入ってくるので、ここの値に応じて、レスポンスを変更することも可能。 res レスポンスを作成するための機能ユーティリティ。 ctx モックされた応答のステータスコード、ヘッダー、本文などを設定するのに役立つ関数のグループ。 Service Workerのコードを生成 mswはService Workerを使用して、APIリクエストをインターセプトします。そのService Workerのコードをプロジェクトの公開ディレクトリに追加するコマンドが用意されています。Reactは、./publicになるので、そちらに追加しましょう。 mockServiceWorkerをpublicディレクトリに生成する $ npx msw init public/ --save 生成されたmockServiceWorker.jsには、Service Workerでのイベント処理が書かれています。 Service Workerを起動するファイルを作成 続いて、Service Workerを生成して起動するために必要なファイルを作成します。 $ touch src/mocks/browser.ts 生成したファイルにはリクエストハンドラーを渡してワーカーインスタンスを作成する処理を書きます。 src/mocks/browser.ts import { setupWorker } from "msw"; import { handlers } from "./handlers"; export const worker = setupWorker(...handlers); エントリポイントにworkerをimportする mswを使用するためには、アプリケーションのエントリポイント( index.tsx )にService Workerのスタート処理をインポートする必要があります。開発環境でのみ、src/mocks/browser.tsファイルをインポートします。(本番環境でmockを使うことはまず無いでしょう) src/index.tsx import React from "react"; import ReactDOM from "react-dom"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; if (process.env.NODE_ENV === "development") { const { worker } = require("./mocks/browser"); worker.start(); } ReactDOM.render( <BrowserRouter> <App /> </BrowserRouter>, document.getElementById("root") ); これで開発時にプロジェクトを立ち上げると、モックも動作するようになりました! 実際に使ってみる アプリケーション起動 $ npm run start consoleに以下の表示がされていれば、mswは正常に動作しています。 あくまで簡易的なものですが、モックをリクエストしてるコンポーネントの実装は以下の通りです。(ログインボタン押下 → Topページへ遷移) src/pages/Login/index.tsx import React from "react"; import { useNavigate } from "react-router-dom"; //React-Router ![Something went wrong]() V6 export const Login = () => { const navigate = useNavigate(); const USER_PAGE = "/user"; const login = () => { fetch("http://localhost:3000/login", { method: "POST" }).then(() => navigate(USER_PAGE) ); }; return <button onClick={login}>login</button>; }; export default Login; src/pages/Home/index.tsx import React, { useEffect, useState } from "react"; import { UserCard } from "../../common/components/UserCard"; import { User } from "../../common/types/User"; export const Home: React.FC = () => { const [user, setUser] = useState<User>({ username: "", age: null, role: "" }); useEffect(() => { const fetchUser = async () => { await fetch("http://localhost:3000/user") .then((res) => { return res.json(); }) .then((res) => { setUser(res); }) .catch((res) => { console.log(res.errorMessage); }); }; fetchUser(); }, []); return ( <> <h1>Hello</h1> <UserCard user={user} /> </> ); }; export default Home; src/common/components/UserCard/index.tsx import React from "react"; import { User } from "../../types/User"; type Props = { user: User; }; export const UserCard: React.FC<Props> = (props: Props) => { const { user } = props; return ( <> <p> 氏名: {user.username}/{user.age}歳 </p> <p>役割: {user.role}</p> </> ); }; DevToolのNewworkタブを見てみましょう。 インターセプトされたリクエストが確認できるでしょうか? 最後に 以上、mswの利用例でした。 以前、Pythonでモックサーバーを作っていた事もあったけど、別途、モック用のサーバーを立てるより、手軽に利用できて良い感じです!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React】React Hook Form のフォーム管理の仕組みを理解する

React Hook Formの基本の使い方や具体的な活用例については、以前の記事でまとめました。 今回は、React Hook Formがフォームの管理をしている仕組みを理解したいと思います。 React Hook Formのメリット 仕組みを理解する前に、特徴や強みを知っておきたいと思います。 公式ドキュメントでは、他のReactのフォームライブラリであるFormikやredux formと比較して、以下のようなメリットが挙げられています。 非制御コンポーネント(Uncontrolled Components)を採用しており、input要素をフォーム全体の再レンダリングなしに監視して変更できるので、レンダリング回数を減らすことが出来る state管理などのコード記述量を減らすことが出来る パッケージが軽量 特徴 上記のようなメリットを可能にしている、React Hook Form最大の特徴は、状態管理をstateとpropsではなくDOMで行う非制御コンポーネント(Uncontrolled Components)でフォームの値を扱うことです。 useFormというカスタムフックが提供するAPIであるregister関数を使用して、参照(ref属性)をuseFormに登録することで、フォームの状態をコントロールしています。 ref…? 非制御コンポーネント…? どうやらReact Hook Formの技術のもとになっているのは、refと非制御コンポーネントというもののようです。 Refとは Reactの公式ドキュメントでは、以下のように書かれています。 Refは、Renderメソッドで作成されたDOMノードやReact要素にアクセスする方法を提供します。 基本的にReactでは、stateが状態を管理・propsがコンポーネント間でデータの受け渡しをする唯一の方法で、子要素を変更するには新しいpropsで再レンダリングをする必要がある。 それに対してRefは、レンダー毎に値が作られるのではないので、レンダーと値が対応しておらず、同じレンダーの中で値を更新することもできる。 ReactにおけるRefには、 Refオブジェクト ref属性 の2つの概念がある。 Refオブジェクト コンポーネントがマウントされた時からアンマウントされるまで存在し続ける。(通常Reactで const obj = { } と記述すると、レンダリング毎に値は同じだが新しいオブジェクトが生成される。) currentというプロパティを持っていおり、ここで値を保持する。 Refオブジェクトでは、currentの値を更新してもReactにそのことが通知されることはない。 RefオブジェクトはReact.createRef()を使用して作成され、ref属性を用いてReact要素に紐づけられる。 ref属性 HTML要素やクラスコンポーネントにはref属性を設定することができ、この属性にはRefオブジェクトを渡すことができる。 先述のようにRefオブジェクトの値の変更がReactに通知されることは無いので、ref属性を設定している要素の状態が変わったタイミングでイベントを発火させることもできない。そのようなことを行いたい場合は、ref属性に関数を渡す必要がある。 このようにRefを使った操作は、Reactのstateとpropsによって制御するという基本的な考え方からは外れており、Reactの公式ドキュメントでも多用することは推奨されていません。 制御コンポーネントと非制御コンポーネントとは 制御コンポーネント フォームの状態をReactのstateで管理する、通常のReactの書き方です。 例えば以下のような感じ。 const ControlledForm = () => { const [name, setName] = useState(''); const handleChange = event => setName(event.target.value); const handleSubmit = () => console.log(name); return ( <div> <input type="text" value={name} onChange={handleChange} */>* <button onClick={handleSubmit} >Submit</button> </div> ); } 入力値を管理するstateを作成 フォームのvalueと作成したstateを同期 ユーザーが入力を変更する度に、stateをユーザーの入力値に更新する 必要なタイミング(送信ボタンが押された時など)でstateの値を取得する というような流れで、stateを使ってフォームの状態を管理します。 (onChangeイベントで値を監視するので)Inputの値は常にstateと同期されます。 言い換えると、入力値が変わるたびに再レンダリングが起きているということです。 非制御コンポーネント stateの更新に対してイベントハンドラを書くのではなく、Refを使用してDOMからフォームの値を取得します。 本来stateで持つデータを、Refを使ってDOM自身で扱います。 const UncontrolledForm = () => { const formRef = React.useRef() // Refオブジェクトの生成 const handleSubmit = event => { event.preventDefault(); console.log(formRef.current.value); } return ( <> <div> <input ref={formRef} type="text" name="name" />   // ref属性に登録 <button onClick={handleSubmit}>Submit</button> </div> </> ); } React.createRef()を用いてDOMを監視するためのRefオブジェクトを作成 inputタグのref属性に、作成したRefオブジェクトを登録 onClickイベントをトリガーにして入力値を取得 というような流れで、inputの値をRefオブジェクトを通じてDOMから取り出すことで、フォームの状態を管理します。 非制御コンポーネントのメリット・デメリット メリット onChangeイベントごとに再レンダリングする制御コンポーネントに比べ、必要なタイミングで値を取得するので軽量 stateに関する記述をなくせる HTML・JavaScriptのネイティブの実装に近いので、他のフレームワーク等へ移植しやすい デメリット 必要なタイミングでDOMから値を取り出すので、入力ごとにvalidationする・入力に対応して表示を変えるなどの処理が難しい。(React Hook Formではオプションにより可能) 改めて、React Hook Formの仕組みとは 非制御コンポーネントでは、通常は入力毎の値を追うことは出来ませんが、React Hook Formでは入力毎にバリデーションを実行することもできます。 どうやって実現しているのかというと、、、 まずReact Hook Formでは、React Hook FormのAPIであるregisterを使って、フォームの要素(inputやselectなど)のref属性に登録しています。 以下のような書き方の部分です。 <input {...register("name", { required: "名前を入力してください" })} type="text" /> そしてregisterからどうやって変更を検知しているのか。 しっかり調べてくださっている方がいました。 以下の方の記事を拝借すると、最終的にregisterは受け取ったrefのaddEventListenerメソッドを呼び出し、blur, change, inputのイベントリスナーを登録しているとのこと。 React Hook Formは非制御コンポーネントからどうやって変更を検知しているのか - commmune Engineer Blog これで、入力値を監視したり変更を検知できているというわけですね。 // React Hook Form のコードから抜粋 ref.addEventListener( shouldAttachChangeEvent ? EVENT.CHANGE : EVENT.INPUT, handleChange, ); ref.addEventListener(EVENTS.BLUR, handleChange); export const EVENTS = { BLUR: 'blur', CHANGE: 'change', INPUT: 'input', } 参考文献
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React Hooks + TypeScript + styled-components で "Netflix"のような滑らかなスライダーを作る

概要 時は大リモートワーク時代...。毎日何かしらのアニメ・ドラマ・映画を流しながら作業をしているという人も多いのではないでしょうか。 自分も例に漏れずそんな感じの日々を送っており、中でも特にお世話になっているサブスクリプションサービスが「Netflix」です。 黒基調のサイトデザインで「クールだなぁ」なんていつも漠然と感じているわけですが、特にスライダー部分はダイナミックな動きで印象的ですよね。 今回はこれに似たスライダーを React Hooks + TypeScript + styled-components で作ってみたいと思います。 完成イメージ https://react-smooth-slider.vercel.app/ ぬるぬる動く滑らかなスライダーになっています。 仕様 言語 TypeScript ライブラリ React styled-components Material-UI 画像データ Pixabay API 下準備編 具体的なコードを書いていく前に、いくつかやっておかなければならない事があるので先に済ませておきましょう。 create-react-app まずは定番のコマンドでアプリの雛型を作成します。 $ npx create-react-app react-smooth-slider --template typescript $ cd react-smooth-slider tsconfig.json に「baseUrl」を追記 ./tsconfig.json { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, ...省略... "baseUrl": "src" ← 追記 }, "include": [ "src" ] } 「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。 これをやっておくと、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになります。 baseUrlを指定しない場合 import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス baseUrlを指定した場合 import Hoge from "components/Hoge" // baseUrlからの相対パス いちいち「../../」みたいな記述を長々としなくて済むので個人的には非常に楽です。 各種ライブラリをインストール 必要なライブラリをインストールします。 $ yarn add @material-ui/core styled-components $ yarn add -D @types/styled-components Material-UIはほとんど使いませんが、後述のローディングアニメーションを作成する際に「CircularProgress」というコンポーネントを拝借したいので一応インストールしてください。 Pixabay API のキーを取得 スライドに使う画像は自前で用意しても良いのですが、せっかくなので今回は「Pixabay」という画像配信サービスのAPIで取得したいと思います。 ドキュメントが整備されていて非常に使いやすく、配信されている画像はどれもクオリティが高いので良い感じのアプリが作れそうです。 https://pixabay.com/ja/service/about/api/ まず、トップページにある「Get Started」ボタンをクリック。 すると、APIのドキュメントが記載されたページに飛ぶのですが、リクエストの際に「APIキー」が必要なことが分かります。 これは無料のユーザー登録をすれば簡単に取得できるので、そのまま「Sign up」をクリックしてください。 適当に情報を入力してユーザー登録を済ませましょう。 ユーザー登録完了後、先ほどのドキュメントページに再びアクセスすると、自分専用のAPIキーが表示されているはず。後で使う事になるのでメモっておいてください。 実装編 諸々の下準備が済んだら、いよいよコードを書いていきましょう。 各種コンポーネント $ mkdir -p src/components src/components/styles $ touch src/components/styles/Slider.ts src/components/styles/SliderItem.ts src/components/styles/index.ts $ touch src/components/Slider.tsx src/components/SliderItem.tsx ./src/components/styles/Slider.ts import styled from "styled-components"; import { StyledSliderItem } from "components/styles"; interface StyledSliderWrapperProps { zoomFactor: number; visibleSlides: number; } export const StyledSliderWrapper = styled.div<StyledSliderWrapperProps>` ${({ zoomFactor, visibleSlides }) => ` overflow: hidden; position: relative; background: #333132; padding: ${(zoomFactor / visibleSlides) * 0.7 + "%"} 0; `} `; interface StyledSliderProps { visibleSlides: number; pageTransition: number; transformValue: string; zoomFactor: number; slideMargin: number; ref: any; } export const StyledSlider = styled.div<StyledSliderProps>` ${({ pageTransition, transformValue }) => ` display: flex; padding: 0 55px; transition: transform ${pageTransition}ms ease; :hover ${StyledSliderItem} { transform: translateX(${transformValue}); } `} `; interface StyledButtonWrapperProps { isForward: boolean; zoomFactor: number; } export const StyledButtonWrapper = styled.div<StyledButtonWrapperProps>` ${({ isForward, zoomFactor }) => ` position: absolute; border-radius: ${isForward ? "0.5vw 0 0 0.5vw" : "0 0.5vw 0.5vw 0"}; box-sizing: border-box; top: 0; ${isForward ? "right: 0;" : "left: 0;"}; width: 55px; height: 100%; padding: ${zoomFactor / 8 + "%"} 0; `} `; interface StyledButtonProps { isForward: boolean; } export const StyledButton = styled.button<StyledButtonProps>` ${({ isForward }) => ` display: block; background: rgb(0, 0, 0, 0.7); border: 0; border-radius: ${isForward ? "0.5vw 0 0 0.5vw" : "0 0.5vw 0.5vw 0"}; top: 0; ${isForward ? "right: 0;" : "left: 0;"}; width: 100%; height: 100%; color: #fff; font-size: 3rem; font-weight: 800; cursor: pointer; outline: none; transition: all 0.7s; user-select: none; :hover { opacity: 0.5; } `} `; ./src/components/styles/SliderItem.ts import styled from "styled-components"; interface StyledSliderItemProps { zoomFactor: number; slideMargin: number; visibleSlides: number; className: string; } export const StyledSliderItem = styled.div<StyledSliderItemProps>` ${({ slideMargin, visibleSlides, zoomFactor }) => ` margin: 0 ${slideMargin}px; transition: transform 500ms ease; border-radius: 0.5vw; cursor: pointer; width: 100%; height: 100%; box-sizing: border-box; display: flex; transform: scale(1); user-select: none; flex: 0 0 calc(100% / ${visibleSlides} - ${slideMargin * 2}px); img { height: 100%; width: 100%; border-radius: 0.5vw; box-sizing: border-box; -webkit-user-drag: none; } :hover { transform: scale(${zoomFactor / 100 + 1}) !important; } :hover ~ * { transform: translateX(${zoomFactor / 2 + "%"}) !important; } &.left { transform-origin: left; :hover ~ * { transform: translateX(${zoomFactor + "%"}) !important; } } &.right { transform-origin: right; :hover ~ * { transform: translateX(0%) !important; } } `} `; ./src/components/styles/index.ts export * from "components/styles/Slider"; export * from "components/styles/SliderItem"; ./src/components/Slider.tsx import React, { useState, useEffect, useRef } from "react"; import { StyledSliderWrapper, StyledSlider, StyledButtonWrapper, StyledButton, } from "components/styles"; import SliderItem from "components/SliderItem"; interface SliderProps { children?: any; zoomFactor: number; slideMargin: number; maxVisibleSlides: number; pageTransition: number; } const Slider: React.FC<SliderProps> = ({ children, zoomFactor, slideMargin, maxVisibleSlides, pageTransition, }) => { const [currentPage, setCurrentPage] = useState<number>(0); const [transformValue, setTransformValue] = useState<string>( `-${zoomFactor / 2}%` ); const [windowWidth, setWindowWith] = useState<number>(0); const sliderRef = useRef<HTMLElement>(null); /* 画面サイズによってスライドの表示枚数を決定 */ const numberOfSlides = ( maxVisibleSlides: number, windowWidth: number ): number => { if (windowWidth > 1024) return maxVisibleSlides; /* パソコンを想定 */ if (windowWidth > 768) return 4; /* タブレットを想定 */ return 3; /* スマホを想定 */ }; const visibleSlides = numberOfSlides(maxVisibleSlides, windowWidth); const totalPages: number = Math.ceil(children.length / visibleSlides) - 1; /* 画面サイズを測定 */ useEffect(() => { const resizeObserver = new ResizeObserver((entries) => { setWindowWith(entries[0].contentRect.width); }); // @ts-ignore resizeObserver.observe(sliderRef.current); }, [sliderRef]); /* スライダーの位置を調整 */ useEffect(() => { if (sliderRef && sliderRef.current) { if (currentPage > totalPages) setCurrentPage(totalPages); sliderRef.current.style.transform = `translate3D(-${ currentPage * windowWidth }px, 0, 0)`; } }, [sliderRef, currentPage, windowWidth, totalPages]); /* ページ推移中はスライドのホバー効果を無効にする(でないと推移中にマウスがスライドの上に乗った場合の見栄えが悪くなってしまう) */ const disableHoverEffect = () => { if (sliderRef.current) sliderRef.current.style.pointerEvents = "none"; setTimeout(() => { if (sliderRef.current) sliderRef.current.style.pointerEvents = "all"; }, pageTransition); }; /* ページを推移させる */ const handleSlideMove = (forward: boolean) => { disableHoverEffect(); setCurrentPage(currentPage + (forward ? 1 : -1)); if (sliderRef.current) sliderRef.current.style.transform = `translate3D(-${ (currentPage + (forward ? 1 : -1)) * windowWidth }px, 0, 0)`; }; /* マウスオーバー時の挙動 */ const handleMouseOver = (id: number) => { if (id % visibleSlides === 1) setTransformValue("0%"); /* left */ if (id % visibleSlides === 0) setTransformValue(`-${zoomFactor}%`); /* right */ }; /* マウスオアウト時の挙動 */ const handleMouseOut = () => { setTransformValue(`-${zoomFactor / 2}%`); }; const assignSlideClass = (index: number, visibleSlides: number) => { const classes = ["right", "left"]; return classes[index % visibleSlides] || ""; }; return ( <StyledSliderWrapper zoomFactor={zoomFactor} visibleSlides={visibleSlides}> <StyledSlider visibleSlides={visibleSlides} transformValue={transformValue} zoomFactor={zoomFactor} slideMargin={slideMargin} pageTransition={pageTransition} ref={sliderRef} > {children.map((child: any, index: number) => ( <SliderItem key={index} slideMargin={slideMargin} visibleSlides={visibleSlides} zoomFactor={zoomFactor} slideClass={assignSlideClass(index + 1, visibleSlides)} id={index + 1} callback={handleMouseOver} callbackOut={handleMouseOut} > {child} </SliderItem> ))} </StyledSlider> {currentPage > 0 && ( /* バックボタン */ <StyledButtonWrapper zoomFactor={zoomFactor} isForward={false}> <StyledButton isForward={false} onClick={() => handleSlideMove(false)} > &#8249; </StyledButton> </StyledButtonWrapper> )} {currentPage !== totalPages && ( /* フォワードボタン */ <StyledButtonWrapper zoomFactor={zoomFactor} isForward={true}> <StyledButton isForward={true} onClick={() => handleSlideMove(true)}> &#8250; </StyledButton> </StyledButtonWrapper> )} </StyledSliderWrapper> ); }; export default Slider; ./src/components/SliderItem.tsx import React from "react"; import { StyledSliderItem } from "components/styles"; interface SliderItemProps { slideClass: string; zoomFactor: number; id: number; callback: (id: number) => void; callbackOut: () => void; slideMargin: number; visibleSlides: number; } const SliderItem: React.FC<SliderItemProps> = ({ slideMargin, visibleSlides, zoomFactor, slideClass, id, callback, callbackOut, children, }) => ( <StyledSliderItem zoomFactor={zoomFactor} slideMargin={slideMargin} visibleSlides={visibleSlides} className={slideClass} onMouseOver={() => callback(id)} onMouseOut={callbackOut} > {children} </StyledSliderItem> ); export default SliderItem; App.tsx ./src/App.tsx import React, { useState, useEffect } from "react"; import { CircularProgress } from "@material-ui/core"; import Slider from "./components/Slider"; const SliderProps = { zoomFactor: 100 /* ホバー時にどれくらいズームするか */, slideMargin: 5 /* スライド間の余白 */, maxVisibleSlides: 5 /* 1ページあたりのスライド枚数*/, pageTransition: 500 /* 次のページへの推移速度 */, }; interface Picture { id: number; webformatURL: string; } const App: React.FC = () => { const [isLoading, setIsLoading] = useState<boolean>(true); const [pictures, setPictures] = useState<Picture[]>([]); /* 画像データは Pixabay API 経由で取得(無料で高画質な写真を提供してくれるサービス) */ const fetchPctures = async () => { const baseUrl = "https://pixabay.com/api/"; const perPage = 20; /* 1ページあたりの取得件数 */ const key = "PIXABAY_API_KEY"; /* Pixabay APIキー */ const query = new URLSearchParams({ per_page: perPage.toString(), key: key, }); /* クエリパラメータを作成 */ const res = await (await fetch(`${baseUrl}?${query}`)).json(); setPictures(res.hits); setIsLoading(false); }; useEffect(() => { fetchPctures(); }, []); /* ローディング中はアニメーションを流す */ if (isLoading) return <CircularProgress />; return ( <Slider {...SliderProps}> {pictures.map((picture) => ( <div key={picture.id}> <img src={picture.webformatURL} alt="slide-pict" /> </div> ))} </Slider> ); }; export default App; https://pixabay.com/api/docs/ Pixabay API を叩く際に付与できるパラメータは色々あるので、ドキュメントを参考に各自お好みで調整してください。 また、レスポンスは以下のような形式になっています。 { "total": 4692, "totalHits": 500, "hits": [ { "id": 195893, "pageURL": "https://pixabay.com/en/blossom-bloom-flower-195893/", "type": "photo", "tags": "blossom, bloom, flower", "previewURL": "https://cdn.pixabay.com/photo/2013/10/15/09/12/flower-195893_150.jpg" "previewWidth": 150, "previewHeight": 84, "webformatURL": "https://pixabay.com/get/35bbf209e13e39d2_640.jpg", "webformatWidth": 640, "webformatHeight": 360, "largeImageURL": "https://pixabay.com/get/ed6a99fd0a76647_1280.jpg", "fullHDURL": "https://pixabay.com/get/ed6a9369fd0a76647_1920.jpg", "imageURL": "https://pixabay.com/get/ed6a9364a9fd0a76647.jpg", "imageWidth": 4000, "imageHeight": 2250, "imageSize": 4731420, "views": 7671, "downloads": 6439, "likes": 5, "comments": 2, "user_id": 48777, "user": "Josch13", "userImageURL": "https://cdn.pixabay.com/user/2013/11/05/02-10-23-764_250x250.jpg", }, { "id": 73424, ... }, ... ] } 取得できる画像のサイズなどにいくつかの選択肢がありますが、今回のスライダーを作成する上では幅640pxの画像で十分だと思うので webformatURL を受け取る想定でコードを書きました。 この辺も色々いじってみると面白いかもしれません。 動作確認 http://localhost:3000/ APIキーなどに間違いが無ければこんな感じで動くはずです。 あとがき 以上、React Hooks + TypeScript + styled-components で "Netflix" のような滑らかなスライダーを作ってみました。 もし動かない部分などがあればGitHubにコードを掲載しているので確認してみてください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React Hooks + TypeScript + styled-components で "Netflix"のように滑らかなスライダーを作る

概要 時は大リモートワーク時代...。毎日何かしらのアニメ・ドラマ・映画を流しながら作業をしているという人も多いのではないでしょうか。 自分も例に漏れずそんな感じの日々を送っており、中でも特にお世話になっているサブスクリプションサービスが「Netflix」です。 黒基調のサイトデザインで「クールだなぁ」なんていつも漠然と感じているわけですが、特にスライダー部分はダイナミックな動きで印象的ですよね。 今回はこれに似たスライダーを React Hooks + TypeScript + styled-components で作ってみたいと思います。 完成イメージ https://react-smooth-slider.vercel.app/ ぬるぬる動く滑らかなスライダーになっています。 仕様 言語 TypeScript ライブラリ React styled-components Material-UI 画像データ Pixabay API 下準備編 具体的なコードを書いていく前に、いくつかやっておかなければならない事があるので先に済ませておきましょう。 create-react-app まずは定番のコマンドでアプリの雛型を作成します。 $ npx create-react-app react-smooth-slider --template typescript $ cd react-smooth-slider tsconfig.json に「baseUrl」を追記 ./tsconfig.json { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, ...省略... "baseUrl": "src" ← 追記 }, "include": [ "src" ] } 「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。 これをやっておくと、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになります。 baseUrlを指定しない場合 import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス baseUrlを指定した場合 import Hoge from "components/Hoge" // baseUrlからの相対パス いちいち「../../」みたいな記述を長々としなくて済むので個人的には非常に楽です。 各種ライブラリをインストール 必要なライブラリをインストールします。 $ yarn add @material-ui/core styled-components $ yarn add -D @types/styled-components Material-UIはほとんど使いませんが、後述のローディングアニメーションを作成する際に「CircularProgress」というコンポーネントを拝借したいので一応インストールしてください。 Pixabay API のキーを取得 スライドに使う画像は自前で用意しても良いのですが、せっかくなので今回は「Pixabay」という画像配信サービスのAPIで取得したいと思います。 ドキュメントが整備されていて非常に使いやすく、配信されている画像はどれもクオリティが高いので良い感じのアプリが作れそうです。 https://pixabay.com/ja/service/about/api/ まず、トップページにある「Get Started」ボタンをクリック。 すると、APIのドキュメントが記載されたページに飛ぶのですが、リクエストの際に「APIキー」が必要なことが分かります。 これは無料のユーザー登録をすれば簡単に取得できるので、そのまま「Sign up」をクリックしてください。 適当に情報を入力してユーザー登録を済ませましょう。 ユーザー登録完了後、先ほどのドキュメントページに再びアクセスすると、自分専用のAPIキーが表示されているはず。後で使う事になるのでメモっておいてください。 実装編 諸々の下準備が済んだら、いよいよコードを書いていきましょう。 各種コンポーネント $ mkdir -p src/components src/components/styles $ touch src/components/styles/Slider.ts src/components/styles/SliderItem.ts src/components/styles/index.ts $ touch src/components/Slider.tsx src/components/SliderItem.tsx ./src/components/styles/Slider.ts import styled from "styled-components"; import { StyledSliderItem } from "components/styles"; interface StyledSliderWrapperProps { zoomFactor: number; visibleSlides: number; } export const StyledSliderWrapper = styled.div<StyledSliderWrapperProps>` ${({ zoomFactor, visibleSlides }) => ` overflow: hidden; position: relative; background: #333132; padding: ${(zoomFactor / visibleSlides) * 0.7 + "%"} 0; `} `; interface StyledSliderProps { visibleSlides: number; pageTransition: number; transformValue: string; zoomFactor: number; slideMargin: number; ref: any; } export const StyledSlider = styled.div<StyledSliderProps>` ${({ pageTransition, transformValue }) => ` display: flex; padding: 0 55px; transition: transform ${pageTransition}ms ease; :hover ${StyledSliderItem} { transform: translateX(${transformValue}); } `} `; interface StyledButtonWrapperProps { isForward: boolean; zoomFactor: number; } export const StyledButtonWrapper = styled.div<StyledButtonWrapperProps>` ${({ isForward, zoomFactor }) => ` position: absolute; border-radius: ${isForward ? "0.5vw 0 0 0.5vw" : "0 0.5vw 0.5vw 0"}; box-sizing: border-box; top: 0; ${isForward ? "right: 0;" : "left: 0;"}; width: 55px; height: 100%; padding: ${zoomFactor / 8 + "%"} 0; `} `; interface StyledButtonProps { isForward: boolean; } export const StyledButton = styled.button<StyledButtonProps>` ${({ isForward }) => ` display: block; background: rgb(0, 0, 0, 0.7); border: 0; border-radius: ${isForward ? "0.5vw 0 0 0.5vw" : "0 0.5vw 0.5vw 0"}; top: 0; ${isForward ? "right: 0;" : "left: 0;"}; width: 100%; height: 100%; color: #fff; font-size: 3rem; font-weight: 800; cursor: pointer; outline: none; transition: all 0.7s; user-select: none; :hover { opacity: 0.5; } `} `; ./src/components/styles/SliderItem.ts import styled from "styled-components"; interface StyledSliderItemProps { zoomFactor: number; slideMargin: number; visibleSlides: number; className: string; } export const StyledSliderItem = styled.div<StyledSliderItemProps>` ${({ slideMargin, visibleSlides, zoomFactor }) => ` margin: 0 ${slideMargin}px; transition: transform 500ms ease; border-radius: 0.5vw; cursor: pointer; width: 100%; height: 100%; box-sizing: border-box; display: flex; transform: scale(1); user-select: none; flex: 0 0 calc(100% / ${visibleSlides} - ${slideMargin * 2}px); img { height: 100%; width: 100%; border-radius: 0.5vw; box-sizing: border-box; -webkit-user-drag: none; } :hover { transform: scale(${zoomFactor / 100 + 1}) !important; } :hover ~ * { transform: translateX(${zoomFactor / 2 + "%"}) !important; } &.left { transform-origin: left; :hover ~ * { transform: translateX(${zoomFactor + "%"}) !important; } } &.right { transform-origin: right; :hover ~ * { transform: translateX(0%) !important; } } `} `; ./src/components/styles/index.ts export * from "components/styles/Slider"; export * from "components/styles/SliderItem"; ./src/components/Slider.tsx import React, { useState, useEffect, useRef } from "react"; import { StyledSliderWrapper, StyledSlider, StyledButtonWrapper, StyledButton, } from "components/styles"; import SliderItem from "components/SliderItem"; interface SliderProps { children?: any; zoomFactor: number; slideMargin: number; maxVisibleSlides: number; pageTransition: number; } const Slider: React.FC<SliderProps> = ({ children, zoomFactor, slideMargin, maxVisibleSlides, pageTransition, }) => { const [currentPage, setCurrentPage] = useState<number>(0); const [transformValue, setTransformValue] = useState<string>( `-${zoomFactor / 2}%` ); const [windowWidth, setWindowWith] = useState<number>(0); const sliderRef = useRef<HTMLElement>(null); /* 画面サイズによってスライドの表示枚数を決定 */ const numberOfSlides = ( maxVisibleSlides: number, windowWidth: number ): number => { if (windowWidth > 1024) return maxVisibleSlides; /* パソコンを想定 */ if (windowWidth > 768) return 4; /* タブレットを想定 */ return 3; /* スマホを想定 */ }; const visibleSlides = numberOfSlides(maxVisibleSlides, windowWidth); const totalPages: number = Math.ceil(children.length / visibleSlides) - 1; /* 画面サイズを測定 */ useEffect(() => { const resizeObserver = new ResizeObserver((entries) => { setWindowWith(entries[0].contentRect.width); }); // @ts-ignore resizeObserver.observe(sliderRef.current); }, [sliderRef]); /* スライダーの位置を調整 */ useEffect(() => { if (sliderRef && sliderRef.current) { if (currentPage > totalPages) setCurrentPage(totalPages); sliderRef.current.style.transform = `translate3D(-${ currentPage * windowWidth }px, 0, 0)`; } }, [sliderRef, currentPage, windowWidth, totalPages]); /* ページ推移中はスライドのホバー効果を無効にする(でないと推移中にマウスがスライドの上に乗った場合の見栄えが悪くなってしまう) */ const disableHoverEffect = () => { if (sliderRef.current) sliderRef.current.style.pointerEvents = "none"; setTimeout(() => { if (sliderRef.current) sliderRef.current.style.pointerEvents = "all"; }, pageTransition); }; /* ページを推移させる */ const handleSlideMove = (forward: boolean) => { disableHoverEffect(); setCurrentPage(currentPage + (forward ? 1 : -1)); if (sliderRef.current) sliderRef.current.style.transform = `translate3D(-${ (currentPage + (forward ? 1 : -1)) * windowWidth }px, 0, 0)`; }; /* マウスオーバー時の挙動 */ const handleMouseOver = (id: number) => { if (id % visibleSlides === 1) setTransformValue("0%"); /* left */ if (id % visibleSlides === 0) setTransformValue(`-${zoomFactor}%`); /* right */ }; /* マウスオアウト時の挙動 */ const handleMouseOut = () => { setTransformValue(`-${zoomFactor / 2}%`); }; const assignSlideClass = (index: number, visibleSlides: number) => { const classes = ["right", "left"]; return classes[index % visibleSlides] || ""; }; return ( <StyledSliderWrapper zoomFactor={zoomFactor} visibleSlides={visibleSlides}> <StyledSlider visibleSlides={visibleSlides} transformValue={transformValue} zoomFactor={zoomFactor} slideMargin={slideMargin} pageTransition={pageTransition} ref={sliderRef} > {children.map((child: any, index: number) => ( <SliderItem key={index} slideMargin={slideMargin} visibleSlides={visibleSlides} zoomFactor={zoomFactor} slideClass={assignSlideClass(index + 1, visibleSlides)} id={index + 1} callback={handleMouseOver} callbackOut={handleMouseOut} > {child} </SliderItem> ))} </StyledSlider> {currentPage > 0 && ( /* バックボタン */ <StyledButtonWrapper zoomFactor={zoomFactor} isForward={false}> <StyledButton isForward={false} onClick={() => handleSlideMove(false)} > &#8249; </StyledButton> </StyledButtonWrapper> )} {currentPage !== totalPages && ( /* フォワードボタン */ <StyledButtonWrapper zoomFactor={zoomFactor} isForward={true}> <StyledButton isForward={true} onClick={() => handleSlideMove(true)}> &#8250; </StyledButton> </StyledButtonWrapper> )} </StyledSliderWrapper> ); }; export default Slider; ./src/components/SliderItem.tsx import React from "react"; import { StyledSliderItem } from "components/styles"; interface SliderItemProps { slideClass: string; zoomFactor: number; id: number; callback: (id: number) => void; callbackOut: () => void; slideMargin: number; visibleSlides: number; } const SliderItem: React.FC<SliderItemProps> = ({ slideMargin, visibleSlides, zoomFactor, slideClass, id, callback, callbackOut, children, }) => ( <StyledSliderItem zoomFactor={zoomFactor} slideMargin={slideMargin} visibleSlides={visibleSlides} className={slideClass} onMouseOver={() => callback(id)} onMouseOut={callbackOut} > {children} </StyledSliderItem> ); export default SliderItem; App.tsx ./src/App.tsx import React, { useState, useEffect } from "react"; import { CircularProgress } from "@material-ui/core"; import Slider from "./components/Slider"; const SliderProps = { zoomFactor: 100 /* ホバー時にどれくらいズームするか */, slideMargin: 5 /* スライド間の余白 */, maxVisibleSlides: 5 /* 1ページあたりのスライド枚数*/, pageTransition: 500 /* 次のページへの推移速度 */, }; interface Picture { id: number; webformatURL: string; } const App: React.FC = () => { const [isLoading, setIsLoading] = useState<boolean>(true); const [pictures, setPictures] = useState<Picture[]>([]); /* 画像データは Pixabay API 経由で取得(無料で高画質な写真を提供してくれるサービス) */ const fetchPctures = async () => { const baseUrl = "https://pixabay.com/api/"; const perPage = 20; /* 1ページあたりの取得件数 */ const key = "PIXABAY_API_KEY"; /* Pixabay APIキー */ const query = new URLSearchParams({ per_page: perPage.toString(), key: key, }); /* クエリパラメータを作成 */ const res = await (await fetch(`${baseUrl}?${query}`)).json(); setPictures(res.hits); setIsLoading(false); }; useEffect(() => { fetchPctures(); }, []); /* ローディング中はアニメーションを流す */ if (isLoading) return <CircularProgress />; return ( <Slider {...SliderProps}> {pictures.map((picture) => ( <div key={picture.id}> <img src={picture.webformatURL} alt="slide-pict" /> </div> ))} </Slider> ); }; export default App; https://pixabay.com/api/docs/ Pixabay API を叩く際に付与できるパラメータは色々あるので、ドキュメントを参考に各自お好みで調整してください。 また、レスポンスは以下のような形式になっています。 { "total": 4692, "totalHits": 500, "hits": [ { "id": 195893, "pageURL": "https://pixabay.com/en/blossom-bloom-flower-195893/", "type": "photo", "tags": "blossom, bloom, flower", "previewURL": "https://cdn.pixabay.com/photo/2013/10/15/09/12/flower-195893_150.jpg" "previewWidth": 150, "previewHeight": 84, "webformatURL": "https://pixabay.com/get/35bbf209e13e39d2_640.jpg", "webformatWidth": 640, "webformatHeight": 360, "largeImageURL": "https://pixabay.com/get/ed6a99fd0a76647_1280.jpg", "fullHDURL": "https://pixabay.com/get/ed6a9369fd0a76647_1920.jpg", "imageURL": "https://pixabay.com/get/ed6a9364a9fd0a76647.jpg", "imageWidth": 4000, "imageHeight": 2250, "imageSize": 4731420, "views": 7671, "downloads": 6439, "likes": 5, "comments": 2, "user_id": 48777, "user": "Josch13", "userImageURL": "https://cdn.pixabay.com/user/2013/11/05/02-10-23-764_250x250.jpg", }, { "id": 73424, ... }, ... ] } 取得できる画像のサイズなどにいくつかの選択肢がありますが、今回のスライダーを作成する上では幅640pxの画像で十分だと思うので webformatURL を受け取る想定でコードを書きました。 この辺も色々いじってみると面白いかもしれません。 動作確認 http://localhost:3000/ APIキーなどに間違いが無ければこんな感じで動くはずです。 あとがき 以上、React Hooks + TypeScript + styled-components で "Netflix" のような滑らかなスライダーを作ってみました。 もし動かない部分などがあればGitHubにコードを掲載しているので確認してみてください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

lodashとNextでつまずいたところをかく。

私は現在lodash+firebase+next.js+materialUIで簡単な連絡帳アプリを構築しています。 その中でも私が特に躓いた部分を記事にしました。 今回は、連絡先の更新機能で躓いた部分を紹介します。 実際に構築したもの 今回紹介するもの 前提としてデータ構造は下記のような形になっています。 interface Contact{ id: string firstName: string lastName: string phoneNumber: string email?: string company?: string avatarUrl?: string address:{ zip?: string prefecture?: string city?: string building?: string street?: string } } [STEP:01]useStateでフォームの内容を管理する この時に、間違って×マークを押して編集画面を閉じてしまっても、編集画面をもう一度クリックしたら、編集されていた内容があるようにしたい。 ※一部抜粋 lodashのcloneDeepを使用することでネストが深いところまで見てコピーしてくれる const [updateContact, setUpdateContact] = useReducer( (state: Contact, data: Partial<Contact>) => _.merge({}, state, data), _.cloneDeep(contact) ) 実際にはこのような形でコピーされるが、lodashのcloneDeepを使用することによって1行かける。 const [updateContact, setUpdateContact] = useReducer( (state: Contact, data: Partial<Contact>) => _.merge({}, state, data), { id: contact.id, firstName: contact.firstName, lastName: contact.lastName, email: contact.email, phoneNumber: contact.phoneNumber, company: contact.company, address: { zip: contact.address?.zip, prefecture: contact.address?.prefecture, city: contact.address?.city, building: contact.address?.building, street: contact.address?.street, }, } ) [注意]lodashにはcloneとcloneDeepがあり、大元のデータを変更しないcloneDeepを使用する。 //これだと、大元のcontactの値を変更してしまう恐れがある。 _.clone(contact) //完全に別物にして、オリジナルのものを壊さない為には、cloneDeepを使う _.cloneDeep(contact) clone https://lodash.com/docs/4.17.15#clone cloneDeepについて https://lodash.com/docs/4.17.15#cloneDeep [STEP:02]フォームの内容が書き換えられたら、useStateを更新する const onChangeUpdate = (e: React.ChangeEvent<HTMLInputElement>) => { //ここでuseStateを更新する。 setUpdateContact(_.set({}, e.target.name, e.target.value)) } return ( ---省略--- <TextField value={updateContact.firstName} name='firstName' label='名前' onChange={onChangeUpdate} fullWidth margin='normal' /> ---省略--- <TextField name='address.street' value={updateContact.address?.street} label='町・番地' fullWidth onChange={onChangeUpdate} margin='normal' placeholder='ねこマム番地' /> ---省略--- ) } lodashのset関数の第二引数はpathを指定する必要がある。 lodash https://lodash.com/docs/4.17.15#set [STEP:03]更新ボタンを押したらcloudfirestoreを更新する ※ withConverterは別で書きます。 const handleUpdate = async () => { const ref = doc( db, 'users', currentUser?.uid as string, 'contacts', contact.id ).withConverter<Contact>(new IdRemover<Contact>()) await updateDoc(ref, updateContact) router.reload() } 全コード(Updateモーダルのみ) interface UpdateDialogProps { contact: Contact isUpdateModalOpen: boolean setIsUpdateModalOpen: React.Dispatch<React.SetStateAction<boolean>> } const UpdateDialog: React.FC<UpdateDialogProps> = ({ contact, isUpdateModalOpen, setIsUpdateModalOpen, }) => { const [updateContact, setUpdateContact] = useReducer( (state: Contact, data: Partial<Contact>) => _.merge({}, state, data), _.cloneDeep(contact) ) const onChangeUpdate = (e: React.ChangeEvent<HTMLInputElement>) => { setUpdateContact(_.set({}, e.target.name, e.target.value)) } const router = useRouter() const { db, currentUser } = useContext(FirebaseContext) const handleUpdate = async () => { const ref = doc( db, 'users', currentUser?.uid as string, 'contacts', contact.id ).withConverter<Contact>(new IdRemover<Contact>()) await updateDoc(ref, updateContact) router.reload() } return ( <Dialog fullScreen open={isUpdateModalOpen}> <AppBar sx={{ position: 'relative' }}> <Toolbar> <IconButton edge='start' color='inherit' onClick={() => setIsUpdateModalOpen(false)}> <CloseIcon /> </IconButton> <Typography sx={{ ml: 2, flex: 1 }} variant='h6' component='div'> 連絡先を編集中 </Typography> <Button color='inherit' onClick={handleUpdate}> 更新する </Button> </Toolbar> </AppBar> <Container> <Typography sx={{ my: 2, flex: 1 }} variant='h5' component='div'> 基本情報 </Typography> <Grid container rowSpacing={1} justifyContent='start'> <TextField value={updateContact.firstName} name='firstName' label='名前' onChange={onChangeUpdate} fullWidth margin='normal' /> <TextField value={updateContact.lastName} name='lastName' label='苗字' placeholder='マムシ' onChange={onChangeUpdate} fullWidth margin='normal' /> <TextField value={updateContact.phoneNumber} name='phoneNumber' label='電話番号' placeholder='1234567890' onChange={onChangeUpdate} fullWidth margin='normal' /> <TextField value={updateContact.email} name='email' label='メールアドレス' placeholder='mamushi@mamushi.com' onChange={onChangeUpdate} fullWidth margin='normal' /> <TextField value={updateContact.company} name='company' label='会社名' placeholder='マムシ株式会社' onChange={onChangeUpdate} fullWidth margin='normal' /> </Grid> <Typography sx={{ my: 2, flex: 1 }} variant='h5' component='div'> 住所 </Typography> <Grid container rowSpacing={1} sx={{ mb: 5 }} justifyContent='start'> <TextField name='address.zip' value={updateContact.address?.zip} label='郵便番号' placeholder='2010017' onChange={onChangeUpdate} fullWidth margin='normal' /> <TextField name='address.prefecture' value={updateContact.address?.prefecture} label='都道府県' placeholder='東京都' onChange={onChangeUpdate} fullWidth margin='normal' /> <TextField name='address.city' value={updateContact.address?.city} label='市町村' placeholder='マムシ区マムシ町' onChange={onChangeUpdate} fullWidth margin='normal' /> <TextField name='address.building' value={updateContact.address?.building} label='ビル・建物名' placeholder='マムシビル' onChange={onChangeUpdate} fullWidth margin='normal' /> <TextField name='address.street' value={updateContact.address?.street} label='町・番地' fullWidth onChange={onChangeUpdate} margin='normal' placeholder='ねこマム番地' /> <Button onClick={handleUpdate} sx={{ my: 3 }} variant='contained'> 更新する </Button> </Grid> </Container> </Dialog> ) }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む