👩🏻💻 이번 프로젝트의 궁극적 목표는 Context API와 Hooks를 이용한 전역 상태 관리를 익히는 것입니다!
(단순히 비주얼적인 과정들은 세세히 기술하지 않았습니다. 깃헙에서 코드 확인 가능합니다!)
// index.html (title 밑에)
<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">
// 'src/App.js'
import Header from "./components/Header";
import Prototypes from "./components/Prototypes";
import Orders from "./components/Orders";
import Footer from "./components/Footer";
function App() {
return (
<>
<Header />
<div className="container">
<Prototypes />
<Orders />
<Footer />
</div>
</>
);
}
export default App;
// 'src/components/Prototypes.jsx
const prototypes = [
{
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 function Prototypes() {
return (
<main>
<div className="prototypes">
{prototypes.map(prototype => {
const {id, thumbnail, title, price, desc, pieUrl} = prototype;
return (
<div className="prototype" key={id}>
<a href={pieUrl} target="_BLANK">
<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">
<i className="icon icon--plus" />
</div>
{title}
</div>
<p className="prototype__price">$ {price}</p>
<desc className="prototype__desc">$ {desc}</desc>
</div>
</div>
);
})}
</div>
</main>
);
}
먼저 prototypes라는 배열 변수에 아이템을 모두 저장해둔 뒤, map()
함수를 사용해 요소를 하나씩 꺼내와 출력하는 방식으로 작성된 코드이다. styled-components만 주구장창 사용하다가 기본 className 정리하려니 왠지 어지러운 느낌이지만.. 시각적 사항들은 나중에 따로 정리해보도록 하겠다!🤦🏻♀️
1) Context 사용하기
먼저 전역 상태 설정 위해 context를, context에 값 주입 위해 provider를 생성한다.
// 'src/contexts/AppStateContext.jsx'
import React from 'react';
const AppStateContext = React.createContext();
export default AppStateContext;
context는 React.createContext()
함수를 이용해 생성한다.
// 'src/providers/AppStateProvider.jsx'
import AppStateContext from "../contexts/AppStateContext";
import { useState } from "react";
const AppStateProvider = ({children}) => {
const [prototypes, setPrototypes] = useState([
{
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 [orders, setOrders] = useState([]);
const addToOrder = useCallback((id) => { },[]);
const remove = useCallback((id) => { },[]);
const removeAll = useCallback(() => { },[]);
// .Provider 주의
return <AppStateContext.Provider value={{
prototypes,
orders,
addToOrder, // 상품 추가 함수
remove, // 상품 삭제 함수
removeAll,
}}>
{children}
</AppStateContext.Provider>
}
export default AppStateProvider;
'src/components/Prototypes.jsx'에 있던 prototypes 배열 전체를 삭제하고 provider로 가져왔다.
Provider가 되는 컴포넌트는 기본 props로 children을 받아와야 한다. 이외 형식은 기본 화살표 함수형 컴포넌트와 다르지 않고, return하는 요소({children}
)를 <AppStateContext.Provider value={...}>
로 감쌌음에 주의하자.
다음으로 위의 Provider에서 제공한 value를 전역으로 공유하기 위해 App 최상위에 Provider 컴포넌트를 감싸준다.
// 'src/App.js'
function App() {
return (
<AppStateProvider>
<Header />
<div className="container">
<Prototypes />
<Orders />
<Footer />
</div>
</AppStateProvider>
);
}
이제 'src/components/Prototypes.jsx'에서 context를 통해 prototypes 데이터를 사용할 준비가 완료 되었다.
// 'src/components/Prototypes.jsx'
const {prototypes} = useContext(AppStateContext);
Prototypes 함수 가장 첫 줄에 위의 코드를 추가해줬다. useContext()
함수로 Provider에서 제공했던 AppStateContext를 받아오며, 동시에 키 값인 prototypes을 지정해 원하는 배열만 저장해왔다.
이제는 useContext()를 직접 활용하지 않고 따로 Hooks으로 빼보도록 하자.
// 'src/hooks/usePrototypes.js'
export default function usePrototypes() {
const {prototypes} = useContext(AppStateContext);
return prototypes;
}
이렇게 따로 hooks 폴더에 컴포넌트를 작성해두면, 아래 코드처럼 context를 사용하는 각 컴포넌트에서 매 번 useContext나 AppStateContext를 import할 필요가 없어진다.
// 'src/components/Prototypes.jsx'
// const {prototypes} = useContext(AppStateContext);
const prototypes = usePrototypes();
👩🏻 따라서 context를 사용할 때,
1) 'src/contexts/AppStateContext.jsx'에서AppStateContext = React.createContext();
export 해주기
2) 'src/providers/AppStateProvider.jsx'에서 AppStateContext를 import 받은 후, AppStateProvider는 기본 props로 children을 받아 return문에 해당 children을<AppStateContext.Provider value={{..}}>
로 감싸며, value에 전달하고싶은 값을 넘겨주기
3) 'src/App.js'에서 최상위에<AppStateProvider>
을 감싸기
4) 각 컴포넌트에서const {prototypes} = useContext(AppStateContext)
와 같이 context 가져와 사용 가능
4-1) 매번 import 귀찮으니, 'src/hooks/usePrototypes.js'에 위처럼 각 context를 받아와 return해주는 훅 작성해두고 사용하기!
2) addToOrder 구현하기
먼저 Prototypes 컴포넌트 내에 클릭 시 addToOrder가 실행되는 코드를 작성한다.
// `src/components/Prototyes.jsx'
...
// map 함수 내
const click = () => {
addToOrder(id);
}
...
// return문 내
<div
className="btn btn--primary float--right"
onClick={click}
>
그럼 addToOrder 역시 context를 사용해 가져와야 하고, 위와 같이 hooks를 따로 작성해야 한다.
// 'src/hooks/useActions.js'
export default function useActions() {
const {addToOrder, remove, removeAll} = useContext(AppStateContext);
return {addToOrder, remove, removeAll};
}
위에 작성했던 usePrototypes와 같은 로직을 가지고 있으면서 action과 관련된 세 가지 요소를 받아와 return해주는 훅이다.
// 'src/components/Prototypes.jsx'
const { addToOrder } = useActions();
Prototypes 컴포넌트 함수 최상단에 위의 코드를 작성해 addToOrder을 사용할 수 있게 하였다.
이제 본격적인 addToOrder 내부 코드를 작성해볼 것이다.
// 'src/providers/AppStateProvider.jsx'
const addToOrder = useCallback((id) => {
// [{id, quantity: 1}]
setOrders(orders => {
const finded = orders.find(order => order.id === id);
if(finded === undefined){
return [...orders, {id, quantity: 1}];
}else{
return orders.map(order => {
if(order.id === id){
return {
id,
quantity: order.quantity + 1
}
} else {
return order;
}
})
}
});
},[]);
addToOrder 함수가 실행되면, setOrders 내에 화살표 함수를 실행하는데, 먼저 find()
함수를 사용해 현재 orders 중 인자로 들어온 id와 같은 id를 가진 order가 있는지 검색한다. 아직 주문이 없다면, 현재 orders와 함께 새로운 id와 수량이 1인 order를 함께 return한다. 이미 주문이 있다면, 다시 orders에 map()
함수를 사용해 각 order에 대해 인자로 들어온 id와 같은 id를 가진 order을 찾아 quantity에 1을 더해 return한다. (이 때 map은 새로운 배열을 반환하는 함수이므로 해당 조건에 부합하는 요소만 +1을 수행하고 나머지는 그대로 알아서 return된다는 점 주의!)
그리고 orders가 제대로 전달되는지 확인하기 위해 아래와 같이 작성했다.
// 'src/hooks/useOrders.js'
export default function useOrders() {
const {orders} = useContext(AppStateContext);
return orders;
}
// 'src/components/Orders.jsx'
import useOrders from "../hooks/useOrders";
export default function Orders() {
const orders = useOrders();
console.log(orders);
return (
...
콘솔 창에 원하는 대로 orders 배열이 잘 출력된다!
3) Orders(주문) 출력하기
현재 작성해둔 코드는 주문 목록이 비었을 때(empty)만을 나타내고 있다. 따라서 주문이 있을 때 내역을 출력하기 위해 Orders 컴포넌트를 다시 작성했다.
// 'src/components/Orders.jsx'
export default function Orders() {
const orders = useOrders();
const prototypes = usePrototypes();
const {remove} = useActions();
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>
</aside>
);
}
다시 useOrders hooks를 이용해 받아온 orders의 length가 0이면 주문이 없음을 나타내는 목록을 출력하고, 그렇지 않으면 각 orders의 각 order에 대해 해당하는 id의 prototype을 찾은 후 썸네일과 이름 및 주문 수량, 그리고 총 가격을 나타내는 목록을 출력하도록 작성했다. 그리고 우측에 x 버튼을 두어 remove 함수를 실행할 수 있도록 코드를 추가했다.
다음은 total 가격을 출력하는 부분을 작성할 것이다.
// 'src/components/Orders.jsx'
...
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]);
...
<div className="total">
<hr />
<div className="item">
<div className="content">Total</div>
<div className="action">
<div className="price">$ {totalPrice}</div>
</div>
</div>
</div>
먼저 컴포넌트 상단에 totalPrice를 계산해두었다. useMemo()
를 사용해 orders나 prototypes가 변경될 때마다, orders에 map을 돌며 각 order에 대해 price와 quantity를 곱한 후 reduce()
로 값을 축적되게 했다. (이후 total 가격 우측에 전체 삭제 버튼, 즉 removeAll을 추가했으나 위의 remove와 그 과정이 매우 유사해 기술하지 않을 것이다.)
그리고 remove와 removeAll 함수는 아래와 같이 간단히 작성했다.
// 'src/providers/AppStateProvider.jsx'
const remove = useCallback((id) => {
setOrders(orders => {
return orders.filter(order => order.id !== id);
});
},[]);
const removeAll = useCallback(() => {
setOrders([]);
},[]);
끝!!!😁
앞에서 포스팅 했듯이, 해당 프로젝트는 패스트캠퍼스 강의를 참고했음을 밝히며, 상단에 링크해둔 깃허브 주소에서 전체 코드 및 커밋 내역을 확인할 수 있습니다!