DjangoとReact redux TypeScriptを使ってオリジナルアプリを作ってみました（TwitterAPI）その4

##前回までのあらすじ
前回まででこのオリジナルアプリのバックエンド,フロントエンドの認証周り、ホーム画面を作成した。

[DjangoとReact redux TypeScriptを使ってオリジナルアプリを作ってみました（TwitterAPI）その1](https://qiita.com/kenshow-blog/items/8d2768b9f4b53cdd4dfd)

[DjangoとReact redux TypeScriptを使ってオリジナルアプリを作ってみました（TwitterAPI）その2](https://qiita.com/kenshow-blog/items/2e1501c3d86d66b16f97)

[DjangoとReact redux TypeScriptを使ってオリジナルアプリを作ってみました（TwitterAPI）その3]
(https://qiita.com/kenshow-blog/items/7ecbd85e00a7e1f0bc75)

git↓
https://github.com/kenshow-blog/twitter

今回はついにフロントエンドの画像収集の画面とバックとの連携をしていく。
これがこのアプリ最後の記事になると思う。

##今回やること
・メディアルームの画像収集するためのフォーム、取得したデータ一覧が格納してあるボタンの作成
・個別ページの作成
・削除ダイアログの作成

##完成形
Mediaルーム
![スクリーンショット 2021-02-21 16.10.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1037585/083a5121-ad34-7b02-1b01-39b81927560b.png)

ここのinputにTwitter上のスクリーンネームを入力する
今回は「tokimeki_cafe」さんのスクリーンネームを入力
（https://twitter.com/tokimeki_cafe）

ロードが開始されバックサイドでそのアカウントの画像収集が始まると同時に個別ページにいくためのボタンが作成される。
![スクリーンショット 2021-02-21 16.16.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1037585/d6b23edf-752f-0f22-bf36-94a7ad6babc9.png)


下記画像のように画像のデータ収集に無事成功しました👍
![スクリーンショット 2021-02-21 16.17.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1037585/3788c1aa-7390-e1d7-16ef-13f628a24883.png)

##アプリ構造

```
フロントエンドディレクトリ階層構造

twitter/twitter_react/
.
├──node_modules
├── README.md
├── package-lock.json
├── package.json
├── public#reduxのデフォルトで用意してあるディレクトリ
├   ├──*****
├── src
│   ├── App.module.css
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── app
│   │   └── store.ts
│   ├── features
│   │   ├── auth #今回作成していく部分
│   │   │   ├── Auth.module.css
│   │   │   ├── Auth.tsx
│   │   │   └── authSlice.ts
│   │   ├── core
│   │   │   ├── Core.module.css
│   │   │   ├── Core.tsx
│   │   │   └── Home.tsx
│   │   ├── counter #reduxのデフォルトで用意してあるディレクトリ
│   │   ├── media
│   │   │   ├── DeleteDialog.tsx
│   │   │   ├── Media.module.css
│   │   │   ├── Media.tsx
│   │   │   ├── MediaDetail.tsx
│   │   │   └── mediaSlice.ts
│   │   └── types.ts
│   ├── index.css
│   ├── index.tsx
│   ├── logo.svg
│   ├── react-app-env.d.ts
│   ├── serviceWorker.ts
│   └── setupTests.ts
└── tsconfig.json
```


##1. 予め型を定義しておく

```ts:twitter_react/src/features/types.ts
export interface LOGIN {
    email: string;
    password: string;
}

export interface REGISTER {
    username: string;
    email: string;
    password: string;
}

export interface POST_IMAGE {
    scrName: string;
}

//ここからが前回から追記したコード
// 個人のユーザーページに行く際に、そのデータを向こう側に渡すため
export interface PROPS_POST_IMAGE {
    id: number;
    scrName: string;
    userPost: number;
    created_on: string;
}

export interface PROPS_IMAGES {
    id: number;
    userImg: number;
    imgs: string;
    imgPost: number;
}

```

scrNameとは、スクリーンネームのことである。
スクリーンネームとは、Twitterのユーザーの「@」より後に続く半角英数字のアカウント名のこと
userPost、userImgは、その画像を収集したユーザーデータが入っている。
imgPostは、ImagePostのこと（外部キー）
スクリーンネームらのデータと画像（１対多)を連結させている。

##2. 画像収集周りの非同期処理を作成する

```ts:twitter_react/src/features/media/mediaSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import axios from "axios";
import { POST_IMAGE, PROPS_POST_IMAGE, PROPS_IMAGES } from "../types";

const apiUrl = process.env.REACT_APP_DEV_API_URL;

export const fetchAsyncGetPostedImage = createAsyncThunk(
    "media/getPostedImage",
    async () => {
        const res = await axios.get(`${apiUrl}media_api/post/`, {
            headers: {
                Authorization: `JWT ${localStorage.localJWT}`,
            },
        });
        return res.data;
    }
);

export const fetchAsyncGetImages = createAsyncThunk(
    "media/getImages",
    async () => {
        const res = await axios.get(`${apiUrl}media_api/imageslist/`, {
            headers: {
                Authorization: `JWT ${localStorage.localJWT}`,
            },
        });
        return res.data;
    }
)
export const fetchAsyncPostImage = createAsyncThunk(
    "media/postImage",
    async (scrName: POST_IMAGE) => {
        const res = await axios.post(`${apiUrl}media_api/post/`, scrName, {
            headers: {
                Authorization: `JWT ${localStorage.localJWT}`,
            },
        });
        return res.data;
    }
);

export const fetchAsyncPostImages = createAsyncThunk(
    "media/Images",
    async (scrName: POST_IMAGE) => {
        const res = await axios.post(`${apiUrl}media_api/images/`, scrName, {
            headers: {
                Authorization: `JWT ${localStorage.localJWT}`,
            },
        });
        return res.data;
    }
);

export const fetcyAsyncDeletePostedImage = createAsyncThunk(
    "media/postedImage/delete",
    async (post_image: PROPS_POST_IMAGE) => {
        const res = await axios.delete(
            `${apiUrl}media_api/post/${post_image.id}/`, {
                headers: {
                    Authorization: `JWT ${localStorage.localJWT}`
                }
            }
        );
        return post_image.id;
        
    }
);

export const fetcyAsyncDeleteImages = createAsyncThunk(
    "media/Images/delete",
    async (images: PROPS_IMAGES) => {
        const res = await axios.delete(
            `${apiUrl}media_api/imageslist/${images.id}/`, {
                headers: {
                    Authorization: `JWT ${localStorage.localJWT}`
                }
            }
        );
        return images;
    }
);

export const mediaSlice = createSlice({
    name: "media",
    initialState: {
        isLoadingPostImage: false,

        post_image: [
            {
               id: 0,
               scrName: "",
               userPost: 0,
               created_on: "" 
            },
        ],
        images: [
            {
                id: 0,
                userImg: 0,
                imgs: "",
                imgPost: 0,
            }
        ] 
    },
    reducers: {
        fetchPostImageStart(state) {
            state.isLoadingPostImage = true;
        },
        fetchPostImageEnd(state) {
            state.isLoadingPostImage = false;
        }
    },
    extraReducers: (builder) => {
        builder.addCase(fetchAsyncGetPostedImage.fulfilled, (state, action) => {
            return {
                ...state,
                post_image: action.payload
            }
        });
        builder.addCase(fetchAsyncGetImages.fulfilled, (state, action) => {
        return {
            ...state,
            images: action.payload
            }
        });
        builder.addCase(fetchAsyncPostImage.fulfilled, (state, action) => {
            return {
                ...state,
                post_image: [...state.post_image, action.payload],
            }
        });

        builder.addCase(fetcyAsyncDeletePostedImage.fulfilled, (state, action) => {
            return {
                ...state,
                post_image: state.post_image.filter((p_image) => 
                    p_image.id !== action.payload
                    
                )
                
            }
            
        });
        builder.addCase(fetcyAsyncDeleteImages.fulfilled, (state, action) => {
            
            return {
                ...state,
                images: state.images.filter((image) => 
                    image.id !== action.payload.id
                )
            }
        });
    },
});

export const {
    fetchPostImageStart,
    fetchPostImageEnd
} = mediaSlice.actions;

export const selectIsLoadingPostImage = (state: RootState) => state.media.isLoadingPostImage;
export const selectPostImage = (state: RootState) => state.media.post_image;
export const selectImages = (state: RootState) => state.media.images;

export default mediaSlice.reducer;
```

###ポイント
・isLoadingPostImageは、画像収集している際に、ロードマークを表示させ、画像収集していない時は非表示になるような処理をしたいため、booleanで定義した。

・fetcyAsyncDeleteらはバックで削除処理をした後、削除したデータ情報をextraReducerに渡した。。
ここは正直,とりあえず動かせるようにした感じだったので、正しいやり方かはわからない感じです、、😅
extraReducerにて、受け取ったデータをそのstate中から外す処理を書いている。
こうすることで、削除後そのデータ情報、画像らは画面に表示されなくなる。


・fetchAsyncPostImageに関しては、あちらにPOSTして、入力されたスクリーンネームらのデータをDBに保存した後、そのデータをres.dataで受け取って、そのデータをstateに追記するべくextraReducerにて、`[...state.post_image, action.payload]`で表現した。

##3. storeにこれらのactionとstateを管理させる
```ts:twitter_react/src/app/store.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import authReducer from '../features/auth/authSlice';
//今回追記した部分↓
import mediaReducer from '../features/media/mediaSlice';

export const store = configureStore({
  reducer: {
    auth: authReducer,
    //今回追記した部分↓
    media: mediaReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;

export type AppDispatch = typeof store.dispatch;
```

##4. メディアコンポーネントの作成

```tsx:twitter_react/src/features/media/Media.tsx
import React, { useEffect, useState } from "react";
import styles from "./Media.module.css";

import { Link } from "react-router-dom";
import { useSelector, useDispatch } from "react-redux";
import { AppDispatch } from "../../app/store";
import {
    fetchPostImageStart,
    fetchPostImageEnd,
    selectIsLoadingPostImage,
    selectPostImage,
    fetchAsyncGetPostedImage,
    fetchAsyncPostImage,
    fetchAsyncPostImages,
    fetcyAsyncDeletePostedImage,
} from "./mediaSlice";
import {setOpenSignIn} from "../auth/authSlice";
import {
    Button,
    Grid,
    CircularProgress
} from "@material-ui/core";
import { DeleteDialog } from "./DeleteDialog";
import { PROPS_POST_IMAGE } from "../types";


export const Media: React.FC = () => {

    const dispatch: AppDispatch = useDispatch();
    const postedImage = useSelector(selectPostImage);
    const isLoadingPostImage = useSelector(selectIsLoadingPostImage);
    const [text, setScrName] = useState("");
    const [commDlg, setCommDlg] = React.useState(false);
    const [usePostImage, setpostImage] = useState({
        id: 0,
        scrName: "",
        userPost: 0,
        created_on: "" 
    });

    const clickPostImage = (value: PROPS_POST_IMAGE) => {
        setpostImage(value)
    }
    useEffect(() => {
        const fetchBootLoader = async () => {
            if(localStorage.localJWT) {
                const result = await dispatch(fetchAsyncGetPostedImage());
                if (fetchAsyncGetPostedImage.rejected.match(result)){
                    dispatch(setOpenSignIn());
                    return null;
                }
            }
        };
        fetchBootLoader();
    }, [dispatch]);
    const postImage = async(e: React.MouseEvent<HTMLElement>) => {
        e.preventDefault();
        const packet = { scrName: text };
        await dispatch(fetchPostImageStart());
        await dispatch(fetchAsyncPostImage(packet));
        await dispatch(fetchAsyncPostImages(packet));
        await dispatch(fetchPostImageEnd());
        setScrName("");
    };
    

    return (
        <div>
            <h1 className={styles.media}>Media</h1>
            <br />
            <br />
            <h2 className={styles.title}>Collecting User's Posted Photos</h2>
            <form className={styles.postImageBox}>
                <input
                    className={styles.postImage_input}
                    type="text"
                    placeholder="Screen Name(later than 「@」)"
                    value={text}
                    onChange={(e) => setScrName(e.target.value)}
                    />
                <button
                disabled={!text.length}
                className={styles.postImage_button}
                type="submit"
                onClick={postImage}
                >
                    Collect
                </button>
                <div className={styles.auth_progress}>
                        {isLoadingPostImage && <CircularProgress/>}
                </div>
            </form>

            
                <h2>Colletion</h2>
                <DeleteDialog
                    msg={"Are you sure you want to permanently delete this files ?"}
                    isOpen={commDlg}
                    doYes={async () => {
                        await dispatch(fetcyAsyncDeletePostedImage(usePostImage))
                        await setCommDlg(false)
                    }}
                    doNo={() => {setCommDlg(false)}}

                />
                <Grid container className={styles.to_detail_grid} spacing={4}>
                    {postedImage
                    .slice(0)
                    .reverse()
                    .map((pimage) =>(
                        <Grid key={pimage.id}  item xs={12} md={4}>
                            <Link to={{   
                                        pathname: `/media/detail/${pimage.id}`,
                                        state: {id: pimage.id,
                                            scrName: pimage.scrName,
                                            userPost: pimage.userPost,
                                            created_on: pimage.created_on}
                                        }}
                                        style={{ textDecoration: 'none' }}>
                                <Button className={styles.to_detail_button} variant="outlined" color="secondary">
                                    {pimage.scrName}
                                </Button>
                            </Link>
                            
                            
                            <Button color="secondary" variant="contained" style={{marginLeft:"10px"}}
                               onClick={ 
                                   () => {
                                    clickPostImage(pimage)
                                    setCommDlg(true)
                                       }} >
                                del
                            </Button>
                            
                        </Grid>
                    ))}
                </Grid>

            



            <p className={styles.button}>
                <Link to="/" style={{ textDecoration: 'none' }}>
                        <Button className={styles.home_button} variant="contained" color="primary">
                            Home
                        </Button>
                </Link>
                </p>
            
        </div>
    )


};
```

###ポイント
・useSelectorを用いて、PostImageとIsLoadingPostImageのstate情報を参照して、postedImage、isLoadingPostImageに格納しておいている

・今回スクリーンネームが入力されるので、そのテキスト情報を保持するべくuseStateを使用した

・削除確認ダイアログに削除したいスクリーンネーム情報を渡すためにここでもuseStateを使用した

・Mediaルームにアクセスした際に、以前収集していたスクリーンネームデータをGetする処理を実装するためにuseEffectを使用した

・別に`DeleteDialog.tsx`を作っているのでそのコンポーネント`<h2>Colletion</h2>`に追記している。詳細については後に解説する。

・`map`と`Grid`を組み合わせて、postImageデータを配列させてる。`reverse()`を書いてるのは、更新日が新しいものを手前に持ってくるようにするため。


##5. Core.tsxの,swichに`/media/`にアクセスしたらメディアコンポーネントを表示させるようにしておく
```tsx:twitter_react/src/features/core/Core.ts
<main className={styles.container}>
                <div className={styles.inner_container}>
                {profile?.username ? <>
                    <BrowserRouter>
                    <Switch>
                        <Route exact path="/" component={Home} />
                        //追記した部分↓
                        <Route exact path="/media" component={Media} />
                     </Switch>
                    </BrowserRouter>

                </> : 
                <>
                </>}
                </div>
            </main>
```

##6. 詳細ページにアクセスした時に、収集した画像一覧を表示するためのコンポーネントを作成する。
```tsx:twitter_react/src/features/media/MediaDetail.tsx
import React, { useEffect } from 'react'
import { PROPS_POST_IMAGE } from "../types";
import { RouteComponentProps, Link } from 'react-router-dom';
import * as H from "history";
import { useSelector, useDispatch } from "react-redux";
import { AppDispatch } from "../../app/store";
import styles from "./Media.module.css";
import { Grid, Button } from "@material-ui/core";

import { 
    fetchAsyncGetImages,
    fetcyAsyncDeleteImages,
    selectImages,
     } from "./mediaSlice";
import {setOpenSignIn} from "../auth/authSlice";

interface Props extends RouteComponentProps<{}> {
    location: H.Location<PROPS_POST_IMAGE>
}


export const MediaDetail: React.FC<Props> = props => {
    let postImage = props.location.state;
    const dispatch: AppDispatch = useDispatch();
    const images = useSelector(selectImages);


    const imagesOnThisPage = images.filter((img) => {
        return img.imgPost === postImage.id
    })
    useEffect(() => {
        const fetchBootLoader = async () => {
            if(localStorage.localJWT) {
                const result = await dispatch(fetchAsyncGetImages());
                if (fetchAsyncGetImages.rejected.match(result)){
                    dispatch(setOpenSignIn());
                    return null;
                }
            }
        };
        fetchBootLoader();
    }, [dispatch]);

    return (
        <div className={styles.image_inner}>
            
            <p className={styles.button_detail}>
                <Link to="/" style={{ textDecoration: 'none', margin: '0 20px 0 20px' }}>
                        <Button className={styles.home_button} variant="contained" color="primary">
                            Home
                        </Button>
                </Link>

                <Link to="/media/" style={{ textDecoration: 'none', margin: '0 20px 0 20px' }}>
                        <Button className={styles.home_button} variant="contained" color="secondary">
                            Media
                        </Button>
                </Link>
                
            </p>
            <div style={{ padding: "5rem 0" }}>
            <h1 className={styles.imgTitle}>{postImage.scrName}'s photos</h1>
            <Grid container className={styles.to_images_grid} spacing={4}>
                {imagesOnThisPage
                .slice(0)
                .reverse()
                .map((image) =>(
                    <Grid key={image.id} item xs={12} md={4}>
                        <img src={image.imgs} alt="" className={styles.image} />
                        <Button color="secondary" variant="contained" style={{marginLeft:"10px"}}
                               onClick={async () => {
                                await dispatch(fetcyAsyncDeleteImages(image))}} >
                        del
                    </Button>
                    </Grid>
                ))}
            </Grid>
            </div> 
        </div>
    )
}
```

###ポイント
・Mediaページからこの詳細ページに遷移する際に、スクリーンネームが書かれたボタンをクリックすると遷移できるのだが、その際に、スクリーンネーム情報を詳細ページに渡さなければならない。なぜならその情報を元に、バックから取得してきた画像の中で、条件に合致する画像だけを　表示できるように実装するためである。
参考記事

[Reactで画面遷移先に値を送る](https://qiita.com/oda3104/items/de5489cd97ba674cbee5)

Mediaで送ったスクリーンネームデータらは`MediaDetail`のlocationに値が入っていることがわかったので、そこから値を取り出して表示する画像の選定に利用した

・画像の配列のやり方はMediaコンポーネントの時と同じ。
それぞれの画像の下に「DEL」ボタンを用意して、それをクリックすると、その画像を削除できるようにしている



ここまででMediaコンポーネント、MediaDetailコンポーネントができた。
表示をキレイにするために、cssで調整した

```css:twitter_react/src/features/media/Media.module.css

.media {
    margin-bottom: 30px;
}
.button {
    margin: 10px
}
.button_detail {
    margin: 10px;
    display: flex;
    justify-content: center;
}
footer {
    width: 100%;
    height: 15px;
    background-color: #F8F8F8;
    position: absolute;
    bottom: 0;
    z-index: -10;
}
.auth_progress {
    margin-top: 15px;
    display: flex;
    justify-content: center;
}
.postImageBox {
    display: flex;
    margin: 0 auto;
    margin-top: 20px;
    margin-bottom: 50px;
    width: 80%;
}
.postImage_input{
    flex: 1;
    padding: 10px;
    border: 2px solid #333333;
    margin-right: 10px;
    
}
.postImage_button {
    flex: 0;
    border: 2px solid #4682b4;
    color: #f8fcfb;
    background-color:#4682b4;
    border-top: 1px solid lightgray;
    cursor: pointer;
    margin-left: 10px;
}
.title{
    margin-bottom: 10px;
}
.home_button {
    padding: 10px 30px !important;
    font-size: 1rem !important;
}
.to_detail_grid {
    margin: 20px !important;
    text-align: center;
    margin-bottom: 50px !important;
}

.to_images_grid {
    margin: 20px !important;
    margin-bottom: 50px !important;
    
}
.to_detail_button,.to_detail_button:hover {
    border: 2px solid #f50057 !important;
    color: #333333 !important;
    font-weight: 600 !important;
}

.image {
    width: 100%;
    height: 280px;
    object-fit: contain;
  }

.image_inner {
    text-align:center;
}
.imgTitle {
    margin-bottom: 70px;
}
```

##7. 削除確認ダイアログを作成する。
これを作成した理由としては、Media画面で、スクリーンネームボタンの隣にある「DEL」 を押した際に、そのスクリーンネームで収集した画像データも全て消えてしまうため、確認画面を設けておきたかった。

ほぼコピペでできた。
参考記事
[React + Material-UIで確認ダイアログを作成してみた。](https://qiita.com/pepaperon_p/items/4e1b25e1e0ebd54268fa)

```tsx:twitter_react/src/features/media/DeleteDialog.tsx
import React, {useEffect} from 'react';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';

export const DeleteDialog: React.FunctionComponent<
    { msg: any, isOpen: any, doYes: any, doNo: any}
    > = ({msg, isOpen, doYes, doNo}) => {

  const [open, setOpen] = React.useState(false)
  useEffect(() => {
    setOpen(isOpen)
  }, [isOpen])

  return (
      <div>
        <Dialog
            open={open}
            keepMounted
            onClose={() => doNo()}
            aria-labelledby="common-dialog-title"
            aria-describedby="common-dialog-description"
        >
          <DialogContent>
            {msg}
          </DialogContent>
          <DialogActions>
            <Button onClick={() => doNo()} color="primary">
              No
            </Button>
            <Button onClick={() => doYes()} color="primary">
              Yes
            </Button>
          </DialogActions>
        </Dialog>
      </div>
  )
}
```

###ポイント

Mediaルームで削除確認ダイアログと関わっているコードを記述しておく

```tsx:Media.tsx
const [usePostImage, setpostImage] = useState({
        id: 0,
        scrName: "",
        userPost: 0,
        created_on: "" 
    });

    const clickPostImage = (value: PROPS_POST_IMAGE) => {
        setpostImage(value)
    }
.
.
.
<DeleteDialog
                    msg={"Are you sure you want to permanently delete this files ?"}
                    isOpen={commDlg}
                    doYes={async () => {
                        await dispatch(fetcyAsyncDeletePostedImage(usePostImage))
                        await setCommDlg(false)
                    }}
                    doNo={() => {setCommDlg(false)}}

/>
.
.
.
<Button color="secondary" variant="contained" style={{marginLeft:"10px"}}
                               onClick={ 
                                   () => {
                                    clickPostImage(pimage)
                                    setCommDlg(true)
                                       }} >
                                del
                  </Button>

```

ざっくり説明すると、削除したいデータをクリック時にsetpostImageにセットしてそのデータをDeleteDialogコンポーネントに渡すようにした感じ。

こちらが完成形

![スクリーンショット 2021-02-24 19.18.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1037585/d81e291e-9826-bcec-4841-7c625c09678b.png)


yesボタンを押せば削除されるし、Noボタンを押せばキャンセルできる。



これで、今回のオリジナルアプリが完成しました🎉🎉🎉


##ここまでの感想
MediaコンポーネントからMediaDetailコンポーネントに遷移する際のデータの受け渡しは相当苦労した。。。
なんとか試行錯誤して`react-router-dom`の`RouteComponentProps`を利用してデータを渡すことができた感じ。
また、TypeScirptで作成していたため、型をどこで定義づければ良いかもよくわからなかったので相当苦戦した。

DeleteDialogコンポーネントに、削除したデータを渡すのも相当大変でした。。。
useStateはテキストの入力以外にも使えるのでは？？と仮説を立てて実装したらうまく動いたのでめっちゃ嬉しかった、、、

私はまだまだ未熟ということもあり、このアプリを完成させるまでに2週間以上かかった、、、、
が、なんとか完成できたのでこれは私にとって大きな一歩と言えるだろう、、、

この記事が少しでも多くのプログラミング学習者の参考になれば幸いです😭
最後に、ここまで読んでくださりありがとうございました！！🙇‍♂️

Twitterもやってますのでよかったらフォローよろしくお願いいたします！！！

Twitterアカウント↓
[健将@WEBエンジニア×明大生](https://twitter.com/kenshow_study)