Woongjae Lee
NHN Dooray - Frontend Team
Senior Lead Software Engineer @NHN Dooray (2021~)
Lead Software Engineer @ProtoPie (2016 ~ 2021)
Microsoft MVP
TypeScript Korea User Group Organizer
Marktube (Youtube)
이 웅재
주소창에 직접 타이핑을 해서 전체 URL 을 변경할 수 있습니다.
주소창에 직접 입력하여 변경하는 경우, 브라우저는 일반적으로 서버로부터 새로운 페이지를 로드하게 되므로 JavaScript 상태와 이벤트 리스너가 초기화됩니다.
CSR (Client Side Rendering) 에서는 보통 어떤 경로 (Path) 로 요청해도 일단 같은 SPA 리소스를 받아서 실행합니다.
그래서 앱이 최초로 실행될 때 경로 (Path) 를 알아내면 됩니다. (window.location)
그리고 나서 경로 (Path) 에 맞는 화면을 보여주게 됩니다.
브라우저의 새로고침(재로딩)을 감지하는 직접적인 방법은 없습니다. 새로고침 버튼이 클릭되면 현재 페이지는 새로 로드되며, 모든 JavaScript의 상태와 이벤트 리스너가 초기화됩니다.
주소창에 직접 타이핑을 해서 전체 URL 을 변경하는 것과 다르지 않습니다.
그래서 앱이 최초로 실행될 때 경로 (Path) 를 알아내면 됩니다. (window.location)
그리고 나서 경로 (Path) 에 맞는 화면을 보여주게 됩니다.
브라우저 주소창 왼편의 이전 (go back), 다음 (go forward) 버튼을 클릭하면, 이전 혹은 다음 URL 로 이동합니다.
브라우저 주소창 왼편의 이전 (go back), 다음 (go forward) 버튼을 우클릭하면, 이전 URL 의 목록이나 다음 URL 의 목록이 나오고, 클릭하면 그리로 이동합니다.
이와 같은 행위를 감지할 수 있는 이벤트인 popstate 이벤트가 존재합니다. window.addEventListener('popstate', 함수) 를 통해 이벤트를 받아서 처리할 수 있습니다.
이러한 목록은 window.history 를 통해 접근이 가능합니다.
보안 이슈로 인해 모든 행위가 다 가능한 것은 아니지만, pushState 와 같은 메서드를 이용하면, 이 목록에 추가가 가능합니다. 이렇게하면, 주소창의 주소가 변경되고 히스토리 목록에도 추가가 됩니다.
하지만, window.history.pushState 를 통해 변경을 할 때는 popstate 이벤트가 발생하지 않습니다.
다른 방법으로 감지해야 합니다.
랜더링된 도큐먼트에서 a 태그나 엘리먼트에 연결된 클릭 이벤트의 리스너로 URL 이동하게 할 경우가 많이 있습니다.
a 태그는 별도의 처리를 하지 않으면, 주소창에 입력하여 변경하는 것과 같은 방식으로 동작합니다.
그래서 이렇게 동작하기 전에 a 태그로 들어온 이벤트를 preventDefault 로 막아주고,
history.pushState 로 직접 주소창을 변경해야 합니다.
버튼은 기본 동작이 URL 변경이 아니므로 기본 동작을 막아줄 필요는 없고,
마찬가지로 history.pushState 로 직접 주소창을 변경해야 합니다.
하지만, 앞에서 알아본 것처럼 pushState 로 변경하면, 이벤트로 감지할 수 없습니다.
<a href="https://www.google.com/">외부 사이트로 이동 하는 링크</a>
<a href="/about">내부 사이트에서 경로를 이동하는 링크</a>
<button>클릭하면 내부 사이트에서 경로를 이동하는 버튼</button>
➜ nvm use 18.17.1
➜ npm i yarn -g
➜ yarn create vite routing-without-history --template react-swc-ts
➜ cd routing-without-history
➜ yarn
➜ code .
➜ yarn dev
history 라이브러리를 사용하면 자바스크립트가 실행되는 모든 곳에서 세션 히스토리를 쉽게 관리할 수 있습니다.
history 객체는 다양한 환경의 차이를 추상화하여 히스토리 스택을 관리하고, 탐색하고, 세션 간 상태를 유지할 수 있는 최소한의 API를 제공합니다.
라이브러리의 용량이 매우 작기 때문에 레포지토리에서 찾아볼 수 있는 파일은 몇 개에 불과합니다.
history version 5는 React Router version 6 에서 사용됩니다.
➜ nvm use 18.17.1
➜ npm i yarn -g
➜ yarn create vite routing-with-history --template react-swc-ts
➜ cd routing-with-history
➜ yarn
➜ yarn add history
➜ code .
➜ yarn dev
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { createBrowserHistory } from "history";
const history = createBrowserHistory();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App history={history} />
</React.StrictMode>
);
import { type BrowserHistory } from "history";
import { useEffect, useState } from "react";
const App: React.FC<{ history: BrowserHistory }> = ({ history }) => {
const [pathname, setPathname] = useState(history.location.pathname);
useEffect(() => {
const unlisten = history.listen(({ location }) => {
console.log("location", location);
if (location.pathname === pathname) return;
setPathname(location.pathname);
});
return () => {
unlisten();
};
}, [history, pathname]);
const goHome = () => {
history.push("/");
};
const goAbout = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const href = event.currentTarget.getAttribute("href");
if (href === null) return;
history.push(href);
};
console.log("render");
return (
<div>
<h1>pathname: {pathname}</h1>
<nav>
<ul>
<li>
<button onClick={goHome}>Home</button>
</li>
<li>
<a href="/about" onClick={goAbout}>
About
</a>
</li>
</ul>
</nav>
<div>
{pathname === "/" && <h2>Home</h2>}
{pathname === "/about" && <h2>About</h2>}
</div>
</div>
);
};
export default App;
React Router 는 가볍고 모든 기능을 갖춘 React 라이브러리용 라우팅 라이브러리입니다.
React Router 는 web, server (node.js), React Native 등 React 가 실행되는 모든 곳에서 실행됩니다.
React Router 레포지토리는 세 가지 패키지를 포함하는 monorepo 입니다:
(router)
history 포함
react-router
React Router 의 핵심이며, react-router-dom 과 react-router-native 모두를 위한 모든 핵심 기능을 제공합니다.
React Router 를 사용하는 경우, react-router 패키지에서 직접 import 하는 것이 아니라, react-router-dom 이나 react-router-native 에 필요한 모든 것이 있습니다.
이 두 패키지는 모두 react-router 에서 모든 것을 export 해줍니다.
react-router-dom
react-router-native
간단한 구조
Redux Toolkit 은 Redux 를 사용하는 데 필요한 복잡한 설정 작업들을 대신 처리해주기 때문에, 더 간단하고 직관적인 구조를 가질 수 있습니다.
간편한 코드 작성
Redux Toolkit 은 createSlice() 함수와 createAsyncThunk() 함수를 제공하여, 보일러플레이트 코드를 줄이고 간단한 코드 작성을 가능하게 합니다.
개발 생산성 향상
Redux Toolkit 을 사용하면 Redux 를 보다 쉽게 사용할 수 있기 때문에, 개발 생산성을 향상시킬 수 있습니다.
최적화된 성능
Redux Toolkit 은 내부적으로 immer.js 와 Redux Toolkit RTK Query 를 사용하여 성능 최적화를 지원합니다.
일부 기능의 제한
Redux Toolkit 은 Redux 의 기능 중 일부를 대신 처리하기 때문에, 모든 기능을 지원하지는 않습니다. 따라서 일부 기능을 직접 구현해야 할 수도 있습니다.
학습 곡선
Redux Toolkit 을 사용하기 위해서는 Redux 의 기본적인 개념과 구조를 이해하고 있어야 합니다. 따라서 Redux 에 익숙하지 않은 개발자에게는 학습 곡선이 있을 수 있습니다.
세부적인 컨트롤의 부재
Redux Toolkit 은 Redux 를 간편하게 사용할 수 있도록 지원하기 때문에, 세부적인 컨트롤을 위해서는 Redux 의 기본 기능을 사용해야 합니다.
의존성 추가
Redux Toolkit 을 사용하기 위해서는 redux 와 immer.js 등의 라이브러리에 대한 의존성이 추가됩니다.
git clone https://github.com/nhn-kai/prototype-shop.git prototype-shop-redux-toolkit
cd prototype-shop-redux-toolkit
nvm use 16
npm ci
npm i @reduxjs/toolkit react-redux
npm start
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { Provider } from "react-redux";
import create from "./redux/create";
const store = create();
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./modules/rootReducer";
export default function create() {
const store = configureStore({
reducer: rootReducer,
});
return store;
}
import prototypes from "./prototypes";
import orders from "./orders";
const rootReducer = { prototypes, orders };
export default rootReducer;
import { createSlice } from "@reduxjs/toolkit";
import { createNamespace } from "../utils";
const namespace = createNamespace("prototypes");
const initialState = {
data: [
{
id: "pp-01",
title: "Kids-story",
artist: "Thomas Buisson",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
price: 10,
pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
},
{
id: "pp-02",
title: "mockyapp",
artist: "Ahmed Amr",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
price: 20,
pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
},
{
id: "pp-03",
title: "macOS Folder Concept",
artist: "Dominik Kandravý",
desc: "Folder concept prototype by Dominik Kandravý.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
price: 30,
pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
},
{
id: "pp-04",
title: "Translator",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
price: 40,
pieUrl: "https://cloud.protopie.io/p/b91edba11d",
},
{
id: "pp-05",
title: "In-car voice control",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
price: 50,
pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
},
{
id: "pp-06",
title: "The Adventures of Proto",
artist: "Richard Oldfield",
desc: `Made exclusively for Protopie Playoff 2021
Shout up if you get stuck!
For the full experience. View in the Protopie App.
#PieDay #PlayOff #ProtoPie`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
price: 60,
pieUrl: "https://cloud.protopie.io/p/95ee13709f",
},
{
id: "pp-07",
title: "Sunglasses shop app",
artist: "Mustafa Alabdullah",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
price: 70,
pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
},
{
id: "pp-08",
title: "Alwritey—Minimalist Text Editor",
artist: "Fredo Tan",
desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
---
Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
---
ProtoPie is an interactive prototyping tool for all digital products.
---
Learn more about ProtoPie at https://protopie.io.`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
price: 80,
pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
},
{
id: "pp-09",
title: "Voice search for TV",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
},
{
id: "pp-10",
title: "Finance App Visual Interaction 2.0",
artist: "Arpit Agrawal",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
price: 90,
pieUrl:
"https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
},
{
id: "pp-11",
title: "Whack-a-mole",
artist: "Changmo Kang",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/ab796f897e",
},
{
id: "pp-12",
title: "Voice Note",
artist: "Haerin Song",
desc: `Made by Haerin Song
(Soda Design)`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
},
],
};
const { reducer } = createSlice({
name: namespace,
initialState,
});
export default reducer;
import { createSlice } from "@reduxjs/toolkit";
import { createNamespace } from "../utils";
const namespace = createNamespace("orders");
const initialState = { data: [] };
const {
actions: { addToOrder, remove, removeAll },
reducer,
} = createSlice({
name: namespace,
initialState,
reducers: {
addToOrder(state, action) {
const id = action.payload.id;
const finded = state.data.find((order) => order.id === id);
if (finded === undefined) {
state.data = [...state.data, { id: id, quantity: 1 }];
} else {
state.data = state.data.map((order) => {
if (order.id === id) {
return {
id,
quantity: order.quantity + 1,
};
} else {
return order;
}
});
}
},
remove(state, action) {
const id = action.payload.id;
state.data = state.data.filter((order) => order.id !== id);
},
removeAll(state) {
state.data = [];
},
},
});
export default reducer;
export { addToOrder, remove, removeAll };
export const createNamespace = (namespace) => `@prototype-shop/${namespace}`;
import Header from "./components/Header";
import PrototypesContainer from "./containers/PrototypesContainer";
import OrdersContainer from "./containers/OrdersContainer";
import Footer from "./components/Footer";
function App() {
return (
<>
<Header />
<div className="container">
<PrototypesContainer />
<OrdersContainer />
<Footer />
</div>
</>
);
}
export default App;
import { useCallback } from "react";
import { addToOrder as addToOrderAction } from "../redux/modules/orders";
import Prototypes from "../components/Prototypes";
import { useSelector, useDispatch } from "react-redux";
export default function PrototypesContainer() {
const prototypes = useSelector((state) => state.prototypes.data);
const dispatch = useDispatch();
const addToOrder = useCallback(
(id) => {
dispatch(addToOrderAction({ id }));
},
[dispatch]
);
return <Prototypes prototypes={prototypes} addToOrder={addToOrder} />;
}
const Prototypes = ({ prototypes, addToOrder }) => (
<main>
<div className="prototypes">
{prototypes.map((prototype) => {
const { id, thumbnail, title, price, desc, pieUrl } = prototype;
const click = () => {
addToOrder(id);
};
return (
<div className="prototype" key={id}>
<a href={pieUrl} target="_BLANK" rel="noreferrer">
<div
style={{
padding: "25px 0 33px 0",
}}
>
<video
autoPlay
loop
playsInline
className="prototype__artwork prototype__edit"
src={thumbnail}
style={{
objectFit: "contain",
}}
/>
</div>
</a>
<div className="prototype__body">
<div className="prototype__title">
<div className="btn btn--primary float--right" onClick={click}>
<i className="icon icon--plus" />
</div>
{title}
</div>
<p className="prototype__price">$ {price}</p>
<p className="prototype__desc">{desc}</p>
</div>
</div>
);
})}
</div>
</main>
);
export default Prototypes;
import Orders from "../components/Orders";
import { useCallback } from "react";
import {
remove as removeAction,
removeAll as removeAllAction,
} from "../redux/modules/orders";
import { useSelector, useDispatch } from "react-redux";
export default function OrdersContainer() {
const prototypes = useSelector((state) => state.prototypes.data);
const orders = useSelector((state) => state.orders.data);
const dispatch = useDispatch();
const remove = useCallback(
(id) => {
dispatch(removeAction({ id }));
},
[dispatch]
);
const removeAll = useCallback(
(id) => {
dispatch(removeAllAction());
},
[dispatch]
);
return (
<Orders
prototypes={prototypes}
orders={orders}
remove={remove}
removeAll={removeAll}
/>
);
}
import { useMemo } from "react";
export default function Orders({ prototypes, orders, remove, removeAll }) {
const totalPrice = useMemo(() => {
return orders
.map((order) => {
const { id, quantity } = order;
const prototype = prototypes.find((p) => p.id === id);
return prototype.price * quantity;
})
.reduce((l, r) => l + r, 0);
}, [orders, prototypes]);
if (orders.length === 0) {
return (
<aside>
<div className="empty">
<div className="title">You don't have any orders</div>
<div className="subtitle">Click on a + to add an order</div>
</div>
</aside>
);
}
return (
<aside>
<div className="order">
<div className="body">
{orders.map((order) => {
const { id } = order;
const prototype = prototypes.find((p) => p.id === id);
const click = () => {
remove(id);
};
return (
<div className="item" key={id}>
<div className="img">
<video src={prototype.thumbnail} />
</div>
<div className="content">
<p className="title">
{prototype.title} x {order.quantity}
</p>
</div>
<div className="action">
<p className="price">$ {prototype.price * order.quantity}</p>
<button className="btn btn--link" onClick={click}>
<i className="icon icon--cross" />
</button>
</div>
</div>
);
})}
</div>
<div className="total">
<hr />
<div className="item">
<div className="content">Total</div>
<div className="action">
<div className="price">$ {totalPrice}</div>
</div>
<button className="btn btn--link" onClick={removeAll}>
<i className="icon icon--delete" />
</button>
</div>
<button
className="btn btn--secondary"
style={{ width: "100%", marginTop: 10 }}
>
Checkout
</button>
</div>
</div>
</aside>
);
}
단순성
MobX 는 Redux 와 비교했을 때 상대적으로 단순합니다. MobX 는 state, action, reaction 의 세 가지 구성 요소로 이루어져 있으며, 이를 이용하여 간단하게 애플리케이션의 상태 관리를 할 수 있습니다.
학습 곡선
MobX 는 상대적으로 쉬운 상태 관리 라이브러리이지만, 다른 상태 관리 라이브러리와는 다른 개념을 사용하므로, 초기에 학습 곡선이 높을 수 있습니다.
비동기적인 데이터 스트림을 다루는 프로그래밍 패러다임 중 하나입니다. 리액티브 프로그래밍에서는 데이터의 상태 변화에 대해 반응(react)하는 것이 중요합니다. 이를 위해 리액티브 프로그래밍에서는 데이터 스트림의 생성, 변환, 필터링, 결합 등을 위한 다양한 연산자를 제공합니다.
리액티브 프로그래밍의 주요 개념으로는 옵저버블(Observable)과 옵서버(Observer)가 있습니다. 옵저버블은 데이터 스트림을 나타내며, 옵서버는 옵저버블에서 발생하는 데이터 변화를 관찰하고 처리합니다. 따라서, 옵저버블에서 데이터 변화가 발생하면 옵서버는 이를 감지하여 처리하게 됩니다.
리액티브 프로그래밍은 비동기적인 데이터 처리를 위해 많이 사용됩니다. 비동기적인 데이터 처리는 이벤트 기반(event-driven)이거나, 콜백(callback) 기반이거나, 프로미스(promise)나, 비동기 함수(async/await) 등을 통해 구현됩니다. 이러한 비동기적인 데이터 처리를 효율적으로 구현하기 위해 리액티브 프로그래밍에서는 다양한 라이브러리와 프레임워크가 제공됩니다.
git clone https://github.com/nhn-kai/prototype-shop.git prototype-shop-mobx
cd prototype-shop-mobx
nvm use 16
npm ci
npm i mobx mobx-react
npm start
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import PrototypesStore from "./stores/PrototypesStore";
import OrdersStore from "./stores/OrdersStore";
import { Provider } from "mobx-react";
const prototypesStore = new PrototypesStore();
const ordersStore = new OrdersStore();
ReactDOM.render(
<React.StrictMode>
<Provider prototypesStore={prototypesStore} ordersStore={ordersStore}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import { computed, makeObservable, observable } from "mobx";
export default class PrototypesStore {
data = [
{
id: "pp-01",
title: "Kids-story",
artist: "Thomas Buisson",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
price: 10,
pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
},
{
id: "pp-02",
title: "mockyapp",
artist: "Ahmed Amr",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
price: 20,
pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
},
{
id: "pp-03",
title: "macOS Folder Concept",
artist: "Dominik Kandravý",
desc: "Folder concept prototype by Dominik Kandravý.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
price: 30,
pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
},
{
id: "pp-04",
title: "Translator",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
price: 40,
pieUrl: "https://cloud.protopie.io/p/b91edba11d",
},
{
id: "pp-05",
title: "In-car voice control",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
price: 50,
pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
},
{
id: "pp-06",
title: "The Adventures of Proto",
artist: "Richard Oldfield",
desc: `Made exclusively for Protopie Playoff 2021
Shout up if you get stuck!
For the full experience. View in the Protopie App.
#PieDay #PlayOff #ProtoPie`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
price: 60,
pieUrl: "https://cloud.protopie.io/p/95ee13709f",
},
{
id: "pp-07",
title: "Sunglasses shop app",
artist: "Mustafa Alabdullah",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
price: 70,
pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
},
{
id: "pp-08",
title: "Alwritey—Minimalist Text Editor",
artist: "Fredo Tan",
desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
---
Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
---
ProtoPie is an interactive prototyping tool for all digital products.
---
Learn more about ProtoPie at https://protopie.io.`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
price: 80,
pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
},
{
id: "pp-09",
title: "Voice search for TV",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
},
{
id: "pp-10",
title: "Finance App Visual Interaction 2.0",
artist: "Arpit Agrawal",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
price: 90,
pieUrl:
"https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
},
{
id: "pp-11",
title: "Whack-a-mole",
artist: "Changmo Kang",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/ab796f897e",
},
{
id: "pp-12",
title: "Voice Note",
artist: "Haerin Song",
desc: `Made by Haerin Song
(Soda Design)`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
},
];
constructor() {
makeObservable(this, {
data: observable,
prototypes: computed,
});
}
get prototypes() {
return this.data;
}
}
import { action, computed, makeObservable, observable } from "mobx";
export default class OrdersStore {
data = [];
constructor() {
makeObservable(this, {
data: observable,
orders: computed,
addToOrder: action,
remove: action,
removeAll: action,
});
}
get orders() {
return this.data;
}
addToOrder = (id) => {
const finded = this.data.find((order) => order.id === id);
if (finded === undefined) {
this.data = [...this.data, { id, quantity: 1 }];
} else {
this.data = this.data.map((order) => {
if (order.id === id) {
return {
id,
quantity: order.quantity + 1,
};
} else {
return order;
}
});
}
};
remove = (id) => {
this.data = this.data.filter((order) => order.id !== id);
};
removeAll = () => {
this.data = [];
};
}
import Header from "./components/Header";
import PrototypesContainer from "./containers/PrototypesContainer";
import OrdersContainer from "./containers/OrdersContainer";
import Footer from "./components/Footer";
function App() {
return (
<>
<Header />
<div className="container">
<PrototypesContainer />
<OrdersContainer />
<Footer />
</div>
</>
);
}
export default App;
import { inject, observer } from "mobx-react";
import Prototypes from "../components/Prototypes";
const PrototypesContainer = inject(({ prototypesStore, ordersStore }) => ({
prototypesStore,
ordersStore,
}))(
observer(({ prototypesStore, ordersStore }) => {
return (
<Prototypes
prototypes={prototypesStore.prototypes}
addToOrder={ordersStore.addToOrder}
/>
);
})
);
export default PrototypesContainer;
const Prototypes = ({ prototypes, addToOrder }) => (
<main>
<div className="prototypes">
{prototypes.map((prototype) => {
const { id, thumbnail, title, price, desc, pieUrl } = prototype;
const click = () => {
addToOrder(id);
};
return (
<div className="prototype" key={id}>
<a href={pieUrl} target="_BLANK" rel="noreferrer">
<div
style={{
padding: "25px 0 33px 0",
}}
>
<video
autoPlay
loop
playsInline
className="prototype__artwork prototype__edit"
src={thumbnail}
style={{
objectFit: "contain",
}}
/>
</div>
</a>
<div className="prototype__body">
<div className="prototype__title">
<div className="btn btn--primary float--right" onClick={click}>
<i className="icon icon--plus" />
</div>
{title}
</div>
<p className="prototype__price">$ {price}</p>
<p className="prototype__desc">{desc}</p>
</div>
</div>
);
})}
</div>
</main>
);
export default Prototypes;
import { inject, observer } from "mobx-react";
import Orders from "../components/Orders";
const OrdersContainer = inject(({ prototypesStore, ordersStore }) => ({
prototypesStore,
ordersStore,
}))(
observer(({ prototypesStore, ordersStore }) => {
return (
<Orders
prototypes={prototypesStore.prototypes}
orders={ordersStore.orders}
remove={ordersStore.remove}
removeAll={ordersStore.removeAll}
/>
);
})
);
export default OrdersContainer;
import { useMemo } from "react";
export default function Orders({ prototypes, orders, remove, removeAll }) {
const totalPrice = useMemo(() => {
return orders
.map((order) => {
const { id, quantity } = order;
const prototype = prototypes.find((p) => p.id === id);
return prototype.price * quantity;
})
.reduce((l, r) => l + r, 0);
}, [orders, prototypes]);
if (orders.length === 0) {
return (
<aside>
<div className="empty">
<div className="title">You don't have any orders</div>
<div className="subtitle">Click on a + to add an order</div>
</div>
</aside>
);
}
return (
<aside>
<div className="order">
<div className="body">
{orders.map((order) => {
const { id } = order;
const prototype = prototypes.find((p) => p.id === id);
const click = () => {
remove(id);
};
return (
<div className="item" key={id}>
<div className="img">
<video src={prototype.thumbnail} />
</div>
<div className="content">
<p className="title">
{prototype.title} x {order.quantity}
</p>
</div>
<div className="action">
<p className="price">$ {prototype.price * order.quantity}</p>
<button className="btn btn--link" onClick={click}>
<i className="icon icon--cross" />
</button>
</div>
</div>
);
})}
</div>
<div className="total">
<hr />
<div className="item">
<div className="content">Total</div>
<div className="action">
<div className="price">$ {totalPrice}</div>
</div>
<button className="btn btn--link" onClick={removeAll}>
<i className="icon icon--delete" />
</button>
</div>
<button
className="btn btn--secondary"
style={{ width: "100%", marginTop: 10 }}
>
Checkout
</button>
</div>
</div>
</aside>
);
}
import { observer } from "mobx-react";
import Prototypes from "../components/Prototypes";
import { useContext } from "react";
import { MobXProviderContext } from "mobx-react";
const PrototypesContainer = observer(() => {
const { prototypesStore, ordersStore } = useContext(MobXProviderContext);
return (
<Prototypes
prototypes={prototypesStore.prototypes}
addToOrder={ordersStore.addToOrder}
/>
);
});
export default PrototypesContainer;
git clone https://github.com/nhn-kai/prototype-shop.git prototype-shop-recoil
cd prototype-shop-recoil
nvm use 16
npm ci
npm i recoil
npm start
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { RecoilRoot } from "recoil";
ReactDOM.render(
<React.StrictMode>
<RecoilRoot>
<App />
</RecoilRoot>
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import Header from "./components/Header";
import PrototypesContainer from "./containers/PrototypesContainer";
import OrdersContainer from "./containers/OrdersContainer";
import Footer from "./components/Footer";
function App() {
return (
<>
<Header />
<div className="container">
<PrototypesContainer />
<OrdersContainer />
<Footer />
</div>
</>
);
}
export default App;
import { useCallback } from "react";
import { useRecoilValue, useRecoilState } from "recoil";
import prototypesState from "../recoil/atoms/prototypesState";
import ordersState from "../recoil/atoms/ordersState";
import Prototypes from "../components/Prototypes";
export default function PrototypesContainer() {
const prototypes = useRecoilValue(prototypesState);
const [orders, setOrders] = useRecoilState(ordersState);
const addToOrder = useCallback(
(id) => {
const finded = orders.find((order) => order.id === id);
if (finded === undefined) {
setOrders([...orders, { id, quantity: 1 }]);
} else {
setOrders(
orders.map((order) => {
if (order.id === id) {
return {
id,
quantity: order.quantity + 1,
};
} else {
return order;
}
})
);
}
},
[orders, setOrders]
);
return <Prototypes prototypes={prototypes} addToOrder={addToOrder} />;
}
import { atom } from "recoil";
const prototypesState = atom({
key: "prototypesState",
default: [
{
id: "pp-01",
title: "Kids-story",
artist: "Thomas Buisson",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
price: 10,
pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
},
{
id: "pp-02",
title: "mockyapp",
artist: "Ahmed Amr",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
price: 20,
pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
},
{
id: "pp-03",
title: "macOS Folder Concept",
artist: "Dominik Kandravý",
desc: "Folder concept prototype by Dominik Kandravý.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
price: 30,
pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
},
{
id: "pp-04",
title: "Translator",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
price: 40,
pieUrl: "https://cloud.protopie.io/p/b91edba11d",
},
{
id: "pp-05",
title: "In-car voice control",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
price: 50,
pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
},
{
id: "pp-06",
title: "The Adventures of Proto",
artist: "Richard Oldfield",
desc: `Made exclusively for Protopie Playoff 2021
Shout up if you get stuck!
For the full experience. View in the Protopie App.
#PieDay #PlayOff #ProtoPie`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
price: 60,
pieUrl: "https://cloud.protopie.io/p/95ee13709f",
},
{
id: "pp-07",
title: "Sunglasses shop app",
artist: "Mustafa Alabdullah",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
price: 70,
pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
},
{
id: "pp-08",
title: "Alwritey—Minimalist Text Editor",
artist: "Fredo Tan",
desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
---
Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
---
ProtoPie is an interactive prototyping tool for all digital products.
---
Learn more about ProtoPie at https://protopie.io.`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
price: 80,
pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
},
{
id: "pp-09",
title: "Voice search for TV",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
},
{
id: "pp-10",
title: "Finance App Visual Interaction 2.0",
artist: "Arpit Agrawal",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
price: 90,
pieUrl:
"https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
},
{
id: "pp-11",
title: "Whack-a-mole",
artist: "Changmo Kang",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/ab796f897e",
},
{
id: "pp-12",
title: "Voice Note",
artist: "Haerin Song",
desc: `Made by Haerin Song
(Soda Design)`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
},
],
});
export default prototypesState;
import { atom } from "recoil";
const ordersState = atom({
key: "ordersState",
default: [],
});
export default ordersState;
import { useCallback, useMemo } from "react";
import { useRecoilValue, useRecoilState } from "recoil";
import prototypesState from "../recoil/atoms/prototypesState";
import ordersState from "../recoil/atoms/ordersState";
import Orders from "../components/Orders";
export default function OrdersContainer() {
const prototypes = useRecoilValue(prototypesState);
const [orders, setOrders] = useRecoilState(ordersState);
const totalPrice = useMemo(() => {
return orders
.map((order) => {
const { id, quantity } = order;
const prototype = prototypes.find((p) => p.id === id);
return prototype.price * quantity;
})
.reduce((l, r) => l + r, 0);
}, [orders, prototypes]);
const remove = useCallback(
(id) => {
setOrders((orders) => {
return orders.filter((order) => order.id !== id);
});
},
[setOrders]
);
const removeAll = useCallback(() => {
setOrders([]);
}, [setOrders]);
return (
<Orders
prototypes={prototypes}
orders={orders}
totalPrice={totalPrice}
remove={remove}
removeAll={removeAll}
/>
);
}
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import { atom } from "jotai";
const prototypesState = atom([
{
id: "pp-01",
title: "Kids-story",
artist: "Thomas Buisson",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
price: 10,
pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
},
{
id: "pp-02",
title: "mockyapp",
artist: "Ahmed Amr",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
price: 20,
pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
},
{
id: "pp-03",
title: "macOS Folder Concept",
artist: "Dominik Kandravý",
desc: "Folder concept prototype by Dominik Kandravý.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
price: 30,
pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
},
{
id: "pp-04",
title: "Translator",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
price: 40,
pieUrl: "https://cloud.protopie.io/p/b91edba11d",
},
{
id: "pp-05",
title: "In-car voice control",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
price: 50,
pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
},
{
id: "pp-06",
title: "The Adventures of Proto",
artist: "Richard Oldfield",
desc: `Made exclusively for Protopie Playoff 2021
Shout up if you get stuck!
For the full experience. View in the Protopie App.
#PieDay #PlayOff #ProtoPie`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
price: 60,
pieUrl: "https://cloud.protopie.io/p/95ee13709f",
},
{
id: "pp-07",
title: "Sunglasses shop app",
artist: "Mustafa Alabdullah",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
price: 70,
pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
},
{
id: "pp-08",
title: "Alwritey—Minimalist Text Editor",
artist: "Fredo Tan",
desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
---
Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
---
ProtoPie is an interactive prototyping tool for all digital products.
---
Learn more about ProtoPie at https://protopie.io.`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
price: 80,
pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
},
{
id: "pp-09",
title: "Voice search for TV",
artist: "Tony Kim",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
},
{
id: "pp-10",
title: "Finance App Visual Interaction 2.0",
artist: "Arpit Agrawal",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
price: 90,
pieUrl:
"https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
},
{
id: "pp-11",
title: "Whack-a-mole",
artist: "Changmo Kang",
desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/ab796f897e",
},
{
id: "pp-12",
title: "Voice Note",
artist: "Haerin Song",
desc: `Made by Haerin Song
(Soda Design)`,
thumbnail:
"https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
price: 90,
pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
},
]);
export default prototypesState;
import { atom } from "jotai";
const ordersState = atom([]);
export default ordersState;
import { useCallback } from "react";
import { useAtom } from "jotai";
import prototypesState from "../jotai/atoms/prototypesState";
import ordersState from "../jotai/atoms/ordersState";
import Prototypes from "../components/Prototypes";
export default function PrototypesContainer() {
const [prototypes] = useAtom(prototypesState);
const [orders, setOrders] = useAtom(ordersState);
const addToOrder = useCallback(
(id) => {
const finded = orders.find((order) => order.id === id);
if (finded === undefined) {
setOrders([...orders, { id, quantity: 1 }]);
} else {
setOrders(
orders.map((order) => {
if (order.id === id) {
return {
id,
quantity: order.quantity + 1,
};
} else {
return order;
}
})
);
}
},
[orders, setOrders]
);
return <Prototypes prototypes={prototypes} addToOrder={addToOrder} />;
}
import { useCallback, useMemo } from "react";
import { useAtom } from "jotai";
import prototypesState from "../jotai/atoms/prototypesState";
import ordersState from "../jotai/atoms/ordersState";
import Orders from "../components/Orders";
export default function OrdersContainer() {
const [prototypes] = useAtom(prototypesState);
const [orders, setOrders] = useAtom(ordersState);
const totalPrice = useMemo(() => {
return orders
.map((order) => {
const { id, quantity } = order;
const prototype = prototypes.find((p) => p.id === id);
return prototype.price * quantity;
})
.reduce((l, r) => l + r, 0);
}, [orders, prototypes]);
const remove = useCallback(
(id) => {
setOrders((orders) => {
return orders.filter((order) => order.id !== id);
});
},
[setOrders]
);
const removeAll = useCallback(() => {
setOrders([]);
}, [setOrders]);
return (
<Orders
prototypes={prototypes}
orders={orders}
totalPrice={totalPrice}
remove={remove}
removeAll={removeAll}
/>
);
}
npx create-react-app react-swr-test
cd react-swr-test
nvm use 16
npm i swr
npm start
import useSWR from "swr";
const fetcher = (...args) => fetch(...args).then((res) => res.json());
export default function SwrTest1() {
const { data, error, isLoading } = useSWR(
"https://api.github.com/users",
fetcher
);
console.log(data, error, isLoading);
return (
<div>
<h1>SwrTest</h1>
{isLoading && <p>Loading...</p>}
{error && <p>Error!</p>}
{data && (
<div>
{data.map((user) => (
<p key={user.login}>{user.login}</p>
))}
</div>
)}
</div>
);
}
import useSWR from "swr";
const fetcher = (...args) => fetch(...args).then((res) => res.json());
function useGithubUser() {
const { data, error, isLoading } = useSWR(
"https://api.github.com/users",
fetcher
);
return { user: data, isLoading, error };
}
function UserList() {
const { user, isLoading, error } = useGithubUser();
return (
<div>
{isLoading && <p>Loading...</p>}
{error && <p>Error!</p>}
{user && <p>{user.map(({ login }) => login).join(",")}</p>}
</div>
);
}
function UserCount() {
const { user, isLoading, error } = useGithubUser();
return (
<div>
{isLoading && <p>Loading...</p>}
{error && <p>Error!</p>}
{user && <div>{user.length}</div>}
</div>
);
}
export default function SwrTest2() {
return (
<div>
<h1>SwrTest2</h1>
<UserList />
<UserCount />
</div>
);
}
nvm install 18
nvm use 18
npx create-next-app my-blog-new
클라이언트 단에서 돌아가지 않는 데이터베이스 및 API 등의 백엔드 서비스에 접근할 수 있다.
보안 키 값들이 클라이언트 단에 드러나지 않도록 지킬 수 있다.
data fetching 과 렌더링을 동일한 환경에서 수행할 수 있다.
서버에 렌더링을 캐싱할 수 있다.
번들링할 자바스크립트 양을 줄일 수 있다.
data fetching이 필요한 경우 Server
백엔드 자원에 접근해야 하는 경우 Server
클라이언트에 드러내면 안 되는 민감한 정보가 있을 때 Server
자바스크립트 코드를 줄여야 할 때 Server
click, change 리스너 등을 사용하여 대화형(상호작용) 컨텐츠를 구현하려는 경우 Client
'상태(state)'을 활용하는 경우 Client
브라우저 상에서만 지원하는 API(예: local storage와 같은 웹 스토리지를 다루는 API)를 사용하는 경우 Client
Next.js에 따르면 우선 위와 같은 기준을 두고, 가능한 경우에는 서버 컴포넌트로 만들고
따로 클라이언트 컴포넌트로 구현이 필요한 부분들만 추출하는 편이 좋다고 한다.
cd my-blog-new
npm i @sanity/client antd @ant-design/icons @sanity/block-content-to-react react-syntax-highlighter @sanity/client dayjs
npm run dev
import styles from "./page.module.css";
import SanityService from "../services/SanityService";
import Header from "./components/Header";
import Footer from "./components/Footer";
import BlogHeadline from "./components/BlogHeadline";
import BlogMainPost from "./components/BlogMainPost";
import BlogList from "./components/BlogList";
async function fetchData() {
const sanityService = new SanityService();
const home = await sanityService.getHome();
const posts = await sanityService.getPosts();
return {
home,
posts,
};
}
export default async function Home() {
const { posts, home } = await fetchData();
const mainPost = posts.find((p) => p.slug === home.mainPostUrl);
const otherPosts = posts.filter((p) => p.slug !== home.mainPostUrl);
return (
<div className={styles.container}>
<Header />
<BlogHeadline />
<BlogMainPost {...mainPost} />
<BlogList posts={otherPosts} />
<Footer />
</div>
);
}
import Header from "../../components/Header";
import SanityService from "../../../services/SanityService";
import Footer from "../../components/Footer";
import BlogPostDetail from "../../components/BlogPostDetail";
import BlogMainPost from "../../components/BlogMainPost";
import styles from "./page.module.css";
export async function generateStaticParams() {
const posts = await new SanityService().getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
async function fetchData(params) {
const posts = await new SanityService().getPosts();
const post = posts.find((p) => p.slug === params.slug);
return post;
}
export default async function Page({ params }) {
const data = await fetchData(params);
console.log(data);
return (
<div className={styles.container}>
<Header />
<BlogMainPost {...data} />
<BlogPostDetail blocks={data.content} />
<Footer />
</div>
);
}
npx serve out
nvm install 18
nvm use 18
npm create sanity@latest -- --template blog --create-project "My Blog Contents New" --dataset production
import {defineField, defineType} from 'sanity'
export default defineType({
name: 'imageGallery',
title: 'ImageGallery',
type: 'object',
fields: [
defineField({
name: 'caption',
title: 'Caption',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'images',
title: 'Images',
type: 'array',
options: {
layout: 'grid',
},
of: [
{
name: 'image',
title: 'Image',
type: 'image',
hotspot: true,
fields: [
{
name: 'alt',
title: 'alt',
type: 'string',
options: {
isHighlighted: true,
},
validation: (Rule) => Rule.required(),
},
],
validation: (Rule) => Rule.required(),
},
],
validation: (Rule) => Rule.required().max(4),
}),
],
})
import {defineField, defineType} from 'sanity'
export default defineType({
name: 'imageGallery',
title: 'ImageGallery',
type: 'object',
fields: [
defineField({
name: 'caption',
title: 'Caption',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'images',
title: 'Images',
type: 'array',
options: {
layout: 'grid',
},
of: [
{
name: 'image',
title: 'Image',
type: 'image',
hotspot: true,
fields: [
{
name: 'alt',
title: 'alt',
type: 'string',
options: {
isHighlighted: true,
},
validation: (Rule) => Rule.required(),
},
],
validation: (Rule) => Rule.required(),
},
],
validation: (Rule) => Rule.required().max(4),
}),
],
})
npm i @sanity/code-input
import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemas'
import {codeInput} from '@sanity/code-input'
export default defineConfig({
name: 'default',
title: 'My Blog Contents New',
projectId: 'hzh6fivm',
dataset: 'production',
plugins: [deskTool(), visionTool(), codeInput()],
schema: {
types: schemaTypes,
},
})
export default defineType({
title: 'Block Content',
name: 'blockContent',
type: 'array',
of: [
...
// You can add additional types here. Note that you can't use
// primitive types such as 'string' and 'number' in the same array
// as a block type.
defineArrayMember({
type: 'image',
options: {hotspot: true},
}),
defineArrayMember({
type: 'video',
options: {hotspot: true},
}),
defineArrayMember({
type: 'code',
options: {hotspot: true},
}),
defineArrayMember({
type: 'link',
options: {hotspot: true},
}),
defineArrayMember({
type: 'imageGallery',
options: {hotspot: true},
}),
],
})
By Woongjae Lee