Snowpack + Cycle: Building a MERN application

Introduction

With the release of webpack v5.0.0 on October 10th, I recently dove into the release notes to prepare an integration plan for the Portal. Through this process, I really came to appreciate the value of starter app templates like Create React App that abstract away the complexity of configuring your environment.

So when I heard about Snowpack, I was immediately interested in the project and followed its development.

Snowpack

On a high-level, Snowpack is unbundled development. Normally, when you save your changes within your code editor, your application must recompile your bundle which takes several seconds if not more. Whereas with Snowpack, when you save your changes, that recompilation time is eliminated. This is because every file only needs to be built once, after that they cache it.

My changes were made before I could even look away from VSCode. Something as small as this felt exciting, whereas I’ve been used to waiting 5-20s for recompilation times, and that’s with Hot Module Replacement (HMR).

Another cool feature is that their dev server starts up almost instantaneously. They claim that it’s normally <50ms whereas traditionlly in a bundled application, your dev server could take anywhere from 30 seconds or more, which is true to what I’ve seen in practice in a large application.

The alternative is bundled development. Almost every popular JavaScript build tool today focuses on bundled development. Running your entire application through a bundler introduces additional work and complexity to your dev workflow that is unnecessary now that ESM is widely supported. Every change – on every save – must be rebundled with the rest of your application before your changes can be reflected in your browser. - From their documentation


Let’s Get Started!

To get up and running, run npx create-snowpack-app my-snowpack-app --template @snowpack/app-template-react-typescript

This will create a folder called my-snowpack-app in your current directory with a React/Typescript template set up for us. If you’re using yarn as your package manager, add --use-yarn to the CLI commands.

A variety of other templates are supported, check here for a full list if you’d like to try something else.

Configs

It’s important we have a good foundation to start with, there’ll be three config files for us to set up to properly use Typescript.

Out of the box, Snowpack does not support non-relative imports. If you’re fine with relative imports, feel free to skip to the next section, but here’s how you’d set them up.

See the configs

We'll be mainly concerned with the baseUrl and paths / alias. Note: baseUrl only accepts a string, so I opted to set up non-relative imports for my frontend files.

// tsconfig.json
{
  "include": ["src", "types"],
  "compilerOptions": {
    "module": "esnext",
    "target": "esnext",
    "moduleResolution": "node",
    "jsx": "preserve",
    "baseUrl": "./src",
    /* paths - If you configure Snowpack import aliases, add them here. */
    "paths": {
      "common": ["./src/common"],
      "store": ["./src/store"]
    },
    /* noEmit - Snowpack builds (emits) files, not tsc. */
    "noEmit": true,
    /* Additional Options */
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "importsNotUsedAsValues": "error"
  }
}
// tsconfig.server.json
{
  "exclude": ["node_modules"],
  "compilerOptions": {
    "baseUrl": "./server",
    "module": "ESNext",
    "esModuleInterop": true,
    "target": "ES5",
    "moduleResolution": "node",
    "sourceMap": true,
    "outDir": "dist",
    "noEmit": false
  },
}
// Lastly, snowpack.config.js
module.exports = {
  baseUrl: "./src", // To match tsconfig.json
  mount: {
    public: '/',
    src: '/_dist_',
  },
  plugins: [
    '@snowpack/plugin-react-refresh',
    '@snowpack/plugin-dotenv',
    '@snowpack/plugin-typescript',
  ],
  alias: {
    common: "./src/common",
    store: "./src/store"
  },
};

Backend

We’re going to start by setting up our backend, the following packages will need to be installed:

# versions included to match what was used during the time of writing

npm install esm@3.2.25 \
  express@4.17.1 \
  mongoose@5.10.11 \
  body-parser@1.19.0 \
  cors@2.8.5

npm install --save-dev @types/node@14.14.5 \
  @types/express@4.17.8 \
  @types/mongoose@5.7.36 \
  @types/body-parser@1.19.0 \
  @types/cors@2.8.8 \
  ts-node@9.0.0

Create server/index.ts

import express from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
import mongoose from 'mongoose';
import type { Request, Response } from 'express';
import { FlashcardsT } from './common/types';

// export type FlashcardsT = {
//   subject: string;
//   description: string;
// };

// Variables
const DBURI =
  import.meta.env.MODE === 'production'
    ? 'mongodb://database.cycle:27017/flashcards' // Plan to host on cycle
    : 'mongodb://localhost:27017/flashcards';

const PORT = import.meta.env.SNOWPACK_PUBLIC_PORT
  ? parseInt(import.meta.env.SNOWPACK_PUBLIC_PORT)
  : 80;

const app = express();
app.use(cors(), bodyParser.json());

app.listen(PORT, '0.0.0.0', () =>
  console.log(`Server started at http://localhost:${PORT}`),
);

// We're using mongoose to simplify a lot of the code behind mongo for this tutorial
mongoose.connect(
  DBURI,
  {
    useNewUrlParser: true,
    useUnifiedTopology: true,
  },
  (err: any) => {
    if (err) {
      console.error('Failed to connect to database...', err);
    }
  },
);

Snowpack’s docs note to use the syntax import.meta.env.<ENV_VAR> to read in environment variables. You’re probably familiar with process.env.NODE_ENV, the Snowpack alternative is import.meta.env.MODE. Also, any environment variable you add in must be prefixed with SNOWPACK_PUBLIC_, this is a common practice that you’ll find in other tools, i.e. REACT_APP_ or GATSBY_, etc.

This is done purposefully as a reminder that environment variables are public and will be shared.

Let’s make our first endpoint to test with, I’ll be adding this below the mongoose connection.

// Let's create our mongoose schema
const Flashcard = new mongoose.Schema<FlashcardsT>({
  subject: String,
  description: String,
});

type FlashcardModelT = {
  subject: string;
  description: string;
} & mongoose.Document;

const cards = mongoose.model<FlashcardModelT>(
  'flashcards',
  Flashcard
);

app.get('/', (req: Request, res: Response) => {
  cards.find((err, cards) => {
    if (err) {
      res.status(404).send(`Error fetching cards, ${err}`);
    } else { 
      res.status(200).json(cards);
      console.log("Successfully fetched cards", cards)
    }
  });
  return;
});

Everything is set up for us to test now!

If you skipped over the Config section of this tutorial, you’re going to want to create and add this config within tsconfig.server.json.

{
  "exclude": ["node_modules"],
  "compilerOptions": {
    "baseUrl": "./server",
    "module": "ESNext",
    "esModuleInterop": true,
    "target": "ES5",
    "moduleResolution": "node",
    "sourceMap": true,
    "outDir": "dist",
    "noEmit": false
  },
}

To run the server, add the following to your `package.json“:

{
  // ...
  // execute this script by typing 'npm run server' in your terminal
  "server": "SNOWPACK_PUBLIC_PORT=8888 npx ts-node --project tsconfig.server.json --files -r esm server/index.ts"
  // ...
}

What this command does is sets the port environment variable to 8888 and uses ts-node to compile our node server code. -r esm allows us to enable esm for local runs — meaning it will load ES modules and export them as CommonJS.

I’ve added a test data point in MongoDB Compass to test if fetching works.

Manual db insertion
curl test to see results

Awesome! Our backend is setup to fetch from our local database. Let’s start tying in some UI elements.

Frontend

The src folder contains everything from the create-react-app starter, delete all except the index.tsx so we can start fresh. Make sure you delete the css import within the file!

But now we’re missing our app component, I’m going to create a pages folder within src and insert app.tsx within.

import React, { FC } from "react";

export const App: FC = () => {
  return <div>Hello World</div>;
};

I’ve reimported App into index.tsx and converted it to a named import.

file structure
import { App } from "./pages/app";

This is a good time to set up some base styles — I’ll be using styled-components, but go ahead and use your option of choice.

Let’s setup our flashcard component.

See the Flashcard component

src/pages/flashcards/flashcard.tsx

import React, { FC, useState } from "react";
import styled from "styled-components";

type FlashcardProps = {};

// essentially our enum to know which side of the card we're viewing
type CardSideT = "front" | "back";

export const Flashcard: FC<FlashcardProps> = ({ }) => {
  const [sideOfCard, setSideOfCard] = useState<CardSideT>("front");
  const [cardIndex, setCardIndex] = useState(0);
  const cardTotalLength = 5; // We'll come back to this!

  return (
    <div
      style={{
        display: 'flex',
        justifyContent: 'center',
        flexDirection: 'column',
        alignItems: 'center',
      }}
    >
      <CardWrapper>
        {/* Icon to decrement index to move to previous card */}
        <LeftArrow
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
          disable={cardIndex === 0}
          onClick={() => setCardIndex((index) => index - 1)}
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"
          />
        </LeftArrow>
        <Card
          side={sideOfCard}
          onClick={() =>
            setSideOfCard(sideOfCard === 'front' ? 'back' : 'front')
          }
        >
          {/* I'll be utilizing Recoil which uses Suspense -- we'll get back to this part, for now you can throw in a div with some placeholder text!*/}
          <React.Suspense fallback={<div>Loading . . .</div>}>
            <CardData index={cardIndex} side={sideOfCard} />
          </React.Suspense>
        </Card>
        {/* Icon to increment index to move to next card */}
        <RightArrow
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
          disable={cardIndex === cardTotalLength - 1}
          onClick={() => setCardIndex((index) => index + 1)}
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
          />
        </RightArrow>
      </CardWrapper>
    </div>
  );
}

const CardWrapper = styled.div`
  display: flex;
  align-items: center;
  justify-content: space-evenly;
`;

const Card = styled.div<{ side: CardSideT }>`
  width: 50rem;
  height: 30rem;
  border: 1px solid lightgray;
  box-shadow: 2px 5px 5px rgba(0, 0, 0, 0.2);
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: white;
  cursor: pointer;

  {/* The background-image style gives the flashcard effect of the multi-colored lines */}
  ${({ side }) => side === "back" && `
      background-image: linear-gradient(to bottom,
          #ffffff 15%,
          #F94545 16%,
          #ffffff 16%,
          #ffffff 25%,
          #85b2d3 26%,
          #ffffff 26%,
          #ffffff 35%,
          #85b2d3 36%,
          #ffffff 36%,
          #ffffff 45%,
          #85b2d3 46%,
          #ffffff 46%,
          #ffffff 55%,
          #85b2d3 56%,
          #ffffff 56%,
          #ffffff 65%,
          #85b2d3 66%,
          #ffffff 66%,
          #ffffff 75%,
          #85b2d3 76%,
          #ffffff 76%,
          #ffffff 85%,
          #85b2d3 86%,
          #ffffff 86%,
          #ffffff 95%,
          #85b2d3 96%,
          #ffffff 96%
      );
    `};
`;

const LeftArrow = styled.svg<{ disable: boolean }>`
  margin-right: 2rem;
  cursor: pointer;
  height: 4rem;
  transition: opacity 0.25s ease-in-out;

  {/* We want to disable / hide the icon altogether if we've reached the boundaries of our card array to not crash the app! */}
  ${({ disable }) => disable && `
    opacity: 0;
    pointer-events: none;
  `};
  &:hover {
    opacity: 0.6;
  }
`;

const RightArrow = styled.svg<{ disable: boolean }>`
  margin-left: 2rem;
  cursor: pointer;
  height: 4rem;
  transition: opacity 0.25s ease-in-out;
  ${({ disable }) => disable && `
    opacity: 0;
    pointer-events: none;
  `};
  &:hover {
    opacity: 0.6;
  }
`;

We have a lot going on in that component, let’s go through some of it.

There are two states we want to manage, which side of the flashcard we are viewing and the index. The cards will be stored as an array that can be iterated through using arrow icons on either side of the cards. I’ve grabbed two arrow svgs from Hero Icons and copied the JSX versions of them.

Feel free to take some creative liberties and choose your own icons and how you want to style your page!

The last items of interest here are the CardData component I am using thats wrapped by React.Suspense. This is where we’re going to utilize Recoil to manage our interaction with the API and store the state!

Recoil

Recoil is being developed to improve on React’s limitations regarding global state, their docs mention wanting to keep the API and semantics as “Reactish” as possible.

As this is being developed by Facebook to be used both internally and openly within the community, it’s likely to gain a lot of traction so I wanted to integrate it and test the API. Do note that this is overkill for this application, so feel free to convert this and utilize local state if desired (or as an exercise!).


Begin by installing npm i recoil and once that is finished, our entire application will need to be wrapped by RecoilRoot to have access to the global state. If you’ve used Redux, this is similar to wrapping our app with Provider and passing in a store.

// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { RecoilRoot } from "recoil";
import { App } from "./pages/app";
import { GlobalStyles } from 'common/styles/global';

ReactDOM.render(
  <React.StrictMode>
    <GlobalStyles />
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById('root'),
);

// Hot Module Replacement (HMR) - Remove this snippet to remove HMR.
// Learn more: https://www.snowpack.dev/#hot-module-replacement
if (import.meta.hot) {
  import.meta.hot.accept();
}

Now, let’s create our “store”.

import { atom, selector } from 'recoil';
import { fetchSettings } from 'common/fetch';

{/*
    export const fetchSettings = (method: "GET" | "POST", data?: any) => ({
      method: method,
      body: data ? JSON.stringify(data) : undefined,
      headers: {
        'Content-Type': 'application/json',
      },
    });
 */}

const endpoint =
  import.meta.env.NODE_ENV === 'production'
    ? window.location.hash
    : `http://localhost:8888`;

export type FlashcardResponseT = {
  id: string;
  subject: string;
  description: string;
};

{/* We give our cardState -- which is our source of truth for our state -- 
    a unique key identifier and the parameter 'default' is its default value,
    which I am using a function to initalize it.

    This function is actually an async selector that will retrieve the data and essentially store the response in the state
*/}
export const cardState = atom({
  key: "flashcard-state",
  default: () => fetchCards || [],
});

export const fetchCards = selector({
  key: 'fetch-flashcards',
  get: async () => {
    const resp = await fetch(endpoint, fetchSettings("GET"));

    if (!resp.ok) {
      throw resp.statusText;
    }

    const respValue: Promise<FlashcardResponseT[]> = resp.json();

    return respValue;
  },
});

// Used to manage the total length of our cards
export const cardArrayTotalLength = atom({
  key: "flashcard-index-state",
  default: 0,
});

Now that we have a basic set up to retrieve our data, lets reflect that in the UI.

Suspense and CardData

But, since React render functions are synchronous, what will it render before the promise resolves? Recoil is designed to work with React Suspense to handle pending data. Wrapping your component with a Suspense boundary will catch any descendants that are still pending and render a fallback UI — documentation

// ... 
<React.Suspense fallback={<div>Loading . . .</div>}>
  <CardData index={cardIndex} side={sideOfCard} />
</React.Suspense>

So while our cards are fetching we’ll display a simple loading indication. When the promise is resolved, our CardData component will be rendered which is what will be displaying the information.

I’ve separated out the data being displayed like this because I wanted the card to remain visible and indicate that the information was loading. If the query for the data was made within our Flashcard component, Suspense would have to then be located within app.tsx wrapping the Flashcard component.

import React, { FC } from "react";
import { useSetRecoilState } from "recoil";
import { useRecoilValue } from "recoil";
import { cardArrayTotalLength, fetchCards } from "store/flashcards";

type CardDataProps = {
  index: number;
  side: "front" | "back";
}

export const CardData: FC<CardDataProps> = ({ index, side }) => {
  const cards = useRecoilValue(fetchCards); // Retrieve our cards

  {/* Because of the confliction of Suspense noted above, I needed a way 
  to tell the parent component 'Flashcards' how many cards are actually 
  in the array, that way I can properly disable the 'next card' icon 
  when I've reached my boundary */}

  // useSetRecoilState returns a function to be able to write to state
  const updateLength = useSetRecoilState(cardArrayTotalLength);
  updateLength(cards.length);

  return (
    <div>
      {side === "front" ? cards[index].subject : cards[index].description}
    </div>
  );
}

At this point, we’ve created a simple UI to display flashcards and have it set up to be able to iterate through multiple cards and retrieve them from a local database. But we don’t want to have to manually insert items into a database to fetch and see this working; let’s add an /add endpoint to our server.

// server/index.ts
// ...

app.post('/add', async (req: Request, res: Response) => { 
  try {
    {/* .execPopulate() is mongoose's way of essentially saying, I want to use async / await, instead of .then */}
    const card = (await cards.create(req.body)).execPopulate();

    if (!card) {
      return res.status(404).json({ message: "Failure adding card" });
    }

    return res.status(200).json(card);

  } catch (e) {
    return res.status(500).json({ message: "Internal server error" });
  }
  
});

With that set up, let’s return to store/flashcards/flashcard.ts and add the function to handle our API “POSTS”.

// This itself doesn't manage the state, but while searching for a way
// to completely handle this through recoil, none of the patterns I saw
// were simple or intuitive so this is a workaround.
// I'm hoping the recoil team adds easier functionality surrounding this
// Something like redux saga and yield call, etc would be fantastic

export const postCards = (value: any) => fetch(endpoint + "/add", fetchSettings("POST", value));

The last addition we’ll do is add some input fields and a submission button.

Add input fields

import React, { FC, useState } from "react";
import { useSetRecoilState } from "recoil";
import { useRecoilCallback } from "recoil";
import { cardState, postCards } from "store/flashcards";
import styled from "styled-components";

type FlashcardInputProps = {};

export const Inputs: FC<FlashcardInputProps> = () => {
  const [subject, setSubject] = useState<string | undefined>(undefined);
  const [description, setDescription] = useState<string | undefined>(undefined);

  const submitHandler = async () => {
    const resp = await postCards({
      subject,
      description,
    });

    if (!resp.ok) {
      throw resp.statusText;
    }

    {/* remember, useSetRecoilState returns a function thats able to write to state */}
    const updateCards = useSetRecoilState(cardState);

    // Set the state / cards with the value returned from our async function
    updateCards(cards => [...cards as any, resp.json() ] as any)
  }

  const removeHandler = async () => {
    {/* As an exercise, try writing the endpoints and management for removing a card! */}
  }

  return (
    <GridForm>
      <InputFieldControl>
        <StyledLabel htmlFor="subject">
          Subject
        </StyledLabel>
        <StyledInput
          type="text"
          name="subject"
          value={subject}
          onInput={(e) => setSubject(e.currentTarget.value)}
        />
      </InputFieldControl>
      <InputFieldControl>
        <StyledLabel htmlFor="description">
          Description
        </StyledLabel>
        <StyledInput
          type="text"
          name="description"
          value={description}
          onInput={(e) => setDescription(e.currentTarget.value)}
        />
      </InputFieldControl>
      <StyledButton onClick={() => submitHandler()}>
        Submit
      </StyledButton>
      <StyledButton onClick={() => removeHandler()}>
        Remove
      </StyledButton>
    </GridForm>
  );
};

const GridForm = styled.form`
  display: grid;
  grid-template-columns: minmax(25rem, 1fr) minmax(25rem, 1fr);
  grid-gap: 2rem;
`;

const InputFieldControl = styled.div`
  display: flex;
  flex-direction: column;
`;

const StyledLabel = styled.label`
  font-weight: 700;
  font-size: 1.4rem;
  letter-spacing: 0.1rem;
`;

const StyledInput = styled.input`
  line-height: 2;
  padding-left: 0.5rem;
`;

const StyledButton = styled.button`
  padding: 1rem;
  cursor: pointer;
`;

simple flashcard UI

Test it out! Submit a subject and a description, you should see your cards update. You could also double check that a network request is made in your browser dev-tools by navigating to the network tab.

Network request response for POSTing a card

Submitting a card works! And you should be able to move through the flashcards as we set that up initially.

Earlier, we declared a const cardTotalLength = 5; we need to replace this with our recoil state that sets the length of the array in our CardData component so that it’s dynamic.

Replace it with const cardTotalLength = useRecoilValue(cardArrayTotalLength);.

You’ll notice that as you flip through the cards, it always stays on the same side of the card. One small improvement we can make is to always reset to the front of the card that way we don’t give any answers away!

// In src/pages/flashcard/flashcard.tsx

useEffect(() => {
  setSideOfCard("front");
}, [cardIndex]);

A good exercise would be to setup a delete endpoint to remove cards, give it a try!

Conclusion

In this tutorial we’ve learned:

  • How to install and utilize Snowpack in our development environment
  • Build a simple MERN application
  • Utilize an experimental state management system to manage the application’s state

Stay tuned for part 2 where I’ll be showing you how to containerize your application and run it on Cycle!