├── next.config.js
├── public
├── favicon.ico
├── devanywhere-logo.png
└── vercel.svg
├── README.md
├── pages
├── _app.js
├── index.js
├── api
│ └── hello-world.js
└── pyramid-of-doom.js
├── src
├── components
│ └── welcome-view.js
├── server
│ ├── hocs
│ │ ├── with-auth.js
│ │ ├── with-request-id.js
│ │ └── with-logger.js
│ └── lib
│ │ ├── default-middleware.js
│ │ └── create-route.js
├── test.js
└── hocs
│ ├── with-features.js
│ ├── with-user.js
│ ├── with-logger.js
│ ├── with-providers.js
│ └── with-layout.js
├── .eslintrc.json
├── .babelrc
├── styles
├── globals.css
└── Home.module.css
├── .gitignore
├── package.json
└── LICENSE
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | reactStrictMode: true,
3 | };
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/paralleldrive/react-component-composition/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Component Composition
2 |
3 | Example repo demonstrating composition patterns for React and Next.js Apps
4 |
--------------------------------------------------------------------------------
/public/devanywhere-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/paralleldrive/react-component-composition/HEAD/public/devanywhere-logo.png
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import "../styles/globals.css";
2 |
3 | function MyApp({ Component, pageProps }) {
4 | return ;
5 | }
6 |
7 | export default MyApp;
8 |
--------------------------------------------------------------------------------
/src/components/welcome-view.js:
--------------------------------------------------------------------------------
1 | const WelcomeView = ({ userName = "" } = {}) => {
2 | return
Welcome {userName}!
;
3 | };
4 |
5 | export default WelcomeView;
6 |
--------------------------------------------------------------------------------
/src/server/hocs/with-auth.js:
--------------------------------------------------------------------------------
1 | const withAuth = async ({ request, response }) => {
2 | console.log("User is authenticated!");
3 |
4 | return { request, response };
5 | };
6 |
7 | export default withAuth;
8 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "plugin:prettier/recommended"
5 | ],
6 | "parserOptions": {
7 | "sourceType": "module",
8 | "ecmaVersion": 2022
9 | }
10 | }
--------------------------------------------------------------------------------
/src/test.js:
--------------------------------------------------------------------------------
1 | import { describe } from "riteway";
2 |
3 | describe("tests", async (assert) => {
4 | assert({
5 | given: "nothing",
6 | should: "run tests",
7 | actual: true,
8 | expected: true,
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/hocs/with-features.js:
--------------------------------------------------------------------------------
1 | const withFeatures = (Component) => {
2 | const features = ["landing-page"];
3 |
4 | return function WithFeatures(props) {
5 | return ;
6 | };
7 | };
8 |
9 | export default withFeatures;
10 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import withProviders from "../src/hocs/with-providers";
3 | import WelcomeView from "../src/components/welcome-view";
4 |
5 | function Home({ user = {} } = {}) {
6 | return ;
7 | }
8 |
9 | export default withProviders()(Home);
10 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "development": {
4 | "presets": [
5 | "next/babel"
6 | ]
7 | },
8 | "production": {
9 | "presets": [
10 | "next/babel"
11 | ]
12 | },
13 | "test": {
14 | "presets": [
15 | "@babel/env",
16 | "@babel/react"
17 | ]
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/src/server/lib/default-middleware.js:
--------------------------------------------------------------------------------
1 | import { asyncPipe } from "./create-route";
2 |
3 | import withAuth from "../hocs/with-auth";
4 | import withRequestId from "../hocs/with-request-id";
5 | import withLogger from "../hocs/with-logger";
6 |
7 | const defaultMiddleware = asyncPipe(withAuth, withRequestId, withLogger);
8 |
9 | export default defaultMiddleware;
10 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | padding: 0;
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
7 | }
8 |
9 | a {
10 | color: inherit;
11 | text-decoration: none;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | }
17 |
--------------------------------------------------------------------------------
/pages/api/hello-world.js:
--------------------------------------------------------------------------------
1 | import createRoute from "../../src/server/lib/create-route";
2 | import defaultMiddleware from "../../src/server/lib/default-middleware";
3 |
4 | const helloWorld = async ({ request, response }) => {
5 | response.status(200);
6 | response.json({ message: "Hello World" });
7 | };
8 |
9 | export default createRoute(defaultMiddleware, helloWorld);
10 |
--------------------------------------------------------------------------------
/src/server/hocs/with-request-id.js:
--------------------------------------------------------------------------------
1 | import cuid from "cuid";
2 |
3 | const appendId = (response) => {
4 | if (!response.locals) response.locals = {};
5 | response.locals.requestId = cuid();
6 | return response;
7 | };
8 |
9 | const withRequestId = async ({ request, response }) => ({
10 | request,
11 | response: appendId(response),
12 | });
13 |
14 | export default withRequestId;
15 |
--------------------------------------------------------------------------------
/src/hocs/with-user.js:
--------------------------------------------------------------------------------
1 | import cuid from "cuid";
2 |
3 | export const createUser = ({
4 | id = cuid(),
5 | name = "Anon",
6 | avatar = "anon.png",
7 | } = {}) => ({
8 | id,
9 | name,
10 | avatar,
11 | });
12 |
13 | const withUser = (Component) => {
14 | const user = createUser();
15 |
16 | return function WithUser(props) {
17 | return ;
18 | };
19 | };
20 |
21 | export default withUser;
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
--------------------------------------------------------------------------------
/src/hocs/with-logger.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | export const log = (x) =>
4 | console.log({
5 | timestamp: Date.now(),
6 | ...x,
7 | });
8 |
9 | const withLogger = (Component) => {
10 | return function LoggingProvider({ ...props }) {
11 | const { user: id } = props;
12 | useEffect(() => {
13 | log({
14 | type: "mount",
15 | name: "MyPage",
16 | user: id,
17 | });
18 | }, [id]);
19 | return ;
20 | };
21 | };
22 |
23 | export default withLogger;
24 |
--------------------------------------------------------------------------------
/src/server/hocs/with-logger.js:
--------------------------------------------------------------------------------
1 | const withLogger = async ({ request, response }) => {
2 | const { body, headers, method, query, url } = request;
3 | const requestId = response.locals.requestId;
4 |
5 | console.log(`${requestId} REQUEST MADE`);
6 | console.log({
7 | requestId,
8 | time: new Date().toISOString(),
9 | url,
10 | method,
11 | headers: JSON.stringify(headers),
12 | body: JSON.stringify(body),
13 | query: JSON.stringify(query),
14 | });
15 |
16 | return { request, response };
17 | };
18 |
19 | export default withLogger;
20 |
--------------------------------------------------------------------------------
/src/hocs/with-providers.js:
--------------------------------------------------------------------------------
1 | import withFeatures from "./with-features.js";
2 | import withLayout from "./with-layout.js";
3 | import withLogger from "./with-logger.js";
4 | import withUser from "./with-user.js";
5 |
6 | const compose =
7 | (...fns) =>
8 | (x) =>
9 | fns.reduceRight((y, f) => f(y), x);
10 |
11 | const withProviders = ({
12 | showBorderDecorations = true,
13 | showFooter = true,
14 | } = {}) =>
15 | compose(
16 | withUser,
17 | withFeatures,
18 | withLogger,
19 | withLayout({ showBorderDecorations, showFooter })
20 | );
21 |
22 | export default withProviders;
23 |
--------------------------------------------------------------------------------
/src/server/lib/create-route.js:
--------------------------------------------------------------------------------
1 | export const asyncPipe =
2 | (...fns) =>
3 | (x) =>
4 | fns.reduce(async (y, f) => f(await y), x);
5 |
6 | const createRoute =
7 | (...middleware) =>
8 | async (request, response) => {
9 | try {
10 | await asyncPipe(...middleware)({
11 | request,
12 | response,
13 | });
14 | } catch (e) {
15 | const requestId = response.locals.requestId;
16 | const { url, method, headers } = request;
17 | console.log({
18 | time: new Date().toISOString(),
19 | body: JSON.stringify(request.body),
20 | query: JSON.stringify(request.query),
21 | method,
22 | headers: JSON.stringify(headers),
23 | error: true,
24 | url,
25 | message: e.message,
26 | stack: e.stack,
27 | requestId,
28 | });
29 | response.status(500);
30 | response.json({
31 | error: "Internal Server Error",
32 | requestId,
33 | });
34 | }
35 | };
36 |
37 | export default createRoute;
38 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-component-composition",
3 | "private": true,
4 | "scripts": {
5 | "dev": "next dev",
6 | "build": "next build",
7 | "start": "next start",
8 | "lint": "eslint --fix . && echo 'Lint complete.'",
9 | "test": "NODE_ENV=test node -r @babel/register -r @babel/core src/test | tap-nirvana",
10 | "watch": "watch \"clear && npm run -s test && npm run -s lint\" src"
11 | },
12 | "dependencies": {
13 | "cuid": "2.1.8",
14 | "next": "12.3.1",
15 | "react": "18.2.0",
16 | "react-dom": "18.2.0"
17 | },
18 | "devDependencies": {
19 | "@babel/core": "7.19.3",
20 | "@babel/preset-env": "7.19.3",
21 | "@babel/preset-react": "7.18.6",
22 | "@babel/register": "7.18.9",
23 | "eslint": "8.24.0",
24 | "eslint-config-next": "12.3.1",
25 | "eslint-config-prettier": "8.5.0",
26 | "eslint-plugin-prettier": "4.2.1",
27 | "eslint-plugin-react": "7.31.8",
28 | "eslint-plugin-react-hooks": "4.6.0",
29 | "prettier": "2.7.1",
30 | "riteway": "6.3.1",
31 | "tap-nirvana": "1.1.0",
32 | "watch": "1.0.2"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Parallel Drive
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/pages/pyramid-of-doom.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect } from "react";
2 | import { BorderDecorations, Footer } from "../src/hocs/with-layout";
3 | import { createUser } from "../src/hocs/with-user";
4 | import { log } from "../src/hocs/with-logger";
5 | import WelcomeView from "../src/components/welcome-view";
6 |
7 | const userContext = createContext();
8 | const UserProvider = userContext.Provider;
9 |
10 | const featureContext = createContext();
11 | const FeatureProvider = featureContext.Provider;
12 | const features = ["landing-page"];
13 |
14 | const PageComponent = () => {
15 | const user = useContext(userContext);
16 | const features = useContext(featureContext);
17 |
18 | useEffect(() => {
19 | log({
20 | type: "mount",
21 | name: "MyPage",
22 | user: user.id,
23 | });
24 | }, [user]);
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 | >
32 | );
33 | };
34 |
35 | const WrappedComponent = () => (
36 |
37 |
38 |
39 |
40 |
41 | );
42 |
43 | export default WrappedComponent;
44 |
--------------------------------------------------------------------------------
/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 0 2rem;
3 | }
4 |
5 | .main {
6 | min-height: 100vh;
7 | padding: 4rem 0;
8 | flex: 1;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | }
14 |
15 | .footer {
16 | display: flex;
17 | flex: 1;
18 | padding: 2rem 0;
19 | border-top: 1px solid #eaeaea;
20 | justify-content: center;
21 | align-items: center;
22 | }
23 |
24 | .footer a {
25 | display: flex;
26 | justify-content: center;
27 | align-items: center;
28 | flex-grow: 1;
29 | }
30 |
31 | .title a {
32 | color: #0070f3;
33 | text-decoration: none;
34 | }
35 |
36 | .title a:hover,
37 | .title a:focus,
38 | .title a:active {
39 | text-decoration: underline;
40 | }
41 |
42 | .title {
43 | margin: 0;
44 | line-height: 1.15;
45 | font-size: 4rem;
46 | }
47 |
48 | .title,
49 | .description {
50 | text-align: center;
51 | }
52 |
53 | .description {
54 | margin: 4rem 0;
55 | line-height: 1.5;
56 | font-size: 1.5rem;
57 | }
58 |
59 | .code {
60 | background: #fafafa;
61 | border-radius: 5px;
62 | padding: 0.75rem;
63 | font-size: 1.1rem;
64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
65 | Bitstream Vera Sans Mono, Courier New, monospace;
66 | }
67 |
68 | .grid {
69 | display: flex;
70 | align-items: center;
71 | justify-content: center;
72 | flex-wrap: wrap;
73 | max-width: 800px;
74 | }
75 |
76 | .card {
77 | margin: 1rem;
78 | padding: 1.5rem;
79 | text-align: left;
80 | color: inherit;
81 | text-decoration: none;
82 | border: 1px solid #eaeaea;
83 | border-radius: 10px;
84 | transition: color 0.15s ease, border-color 0.15s ease;
85 | max-width: 300px;
86 | }
87 |
88 | .card:hover,
89 | .card:focus,
90 | .card:active {
91 | color: #0070f3;
92 | border-color: #0070f3;
93 | }
94 |
95 | .card h2 {
96 | margin: 0 0 1rem 0;
97 | font-size: 1.5rem;
98 | }
99 |
100 | .card p {
101 | margin: 0;
102 | font-size: 1.25rem;
103 | line-height: 1.5;
104 | }
105 |
106 | .logo {
107 | height: 1em;
108 | margin-left: 0.5rem;
109 | }
110 |
111 | @media (max-width: 600px) {
112 | .grid {
113 | width: 100%;
114 | flex-direction: column;
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/hocs/with-layout.js:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | export const Footer = () => {
4 | return (
5 | <>
6 |
20 |
61 | >
62 | );
63 | };
64 |
65 | export const BorderDecorations = () => {
66 | return (
67 | <>
68 |
71 |
74 |
92 | >
93 | );
94 | };
95 |
96 | const withLayout =
97 | ({ showBorderDecorations = false, showFooter = true }) =>
98 | (Component) => {
99 | return function WithLayout(props) {
100 | return (
101 | <>
102 | {showBorderDecorations && }
103 |
104 | {showFooter && }
105 | >
106 | );
107 | };
108 | };
109 |
110 | export default withLayout;
111 |
--------------------------------------------------------------------------------