But why?
At ST6 we believe that great people build great products. But having great people is not enough, you need to build an environment which will allow those people to flourish. You need to nurture a strong culture that will reinforce your core values.
What can be more valuable than having your own meme generator that can be used to spread your ideas, behaviors, styles, or values within or outside of your organization. After all
"Every software organization should have its own meme generator".
... but please don't quote me on that on your next all-hands meeting.
Let's leave the cultural story for another blog post and focus on the technology. This was supposed to be a technical post.
We love React and as soon as Hooks were officially released we knew we want to try them out. We were keen to rewrite all our code with Hooks (without bringing any value to our customers) but eventually decided to used them in a fresh project of ours because we love sarcasm and bad jokes.
And that's how it came to be: Memes + Hooks = Awesomeness
.
TL;DR If you want to generate your own memes go to https://st6.io/meme. The source code is at https://github.com/st6io/meme.
Bootstrapping the meme generator app
It's 2019 and if you are not using Create React App to bootstrap your next React app you are doing it wrong. Gone are the days when you have to spend weeks evaluating different starter kits, because none match your preferences. Create React App does all the configuration for you and comes with great defaults. With version 2 there is even less need to eject
the tooling, because it comes with CSS modules, Sass and Babel macros support. Running
yarn create react-app meme
... and we are done.
After yarning the app with Create React App we are extending the setup with the following configurations.
Pinning the Node.js version
We want to pin the Node.js version in order to avoid hard to reproduce issues and ensure all two of us are using the same version of Node while working on the app. We are using .nvmrc
(and nvm) that usually points to the latest LTS version of Node at the time of bootstrapping. Additional benefit of locking the version is that most popular CI providers do support it which yields that they will use the specified version of Node while running your CI pipeline.
Installing code formatting tools
We are huge fans of Prettier and install it in every project we work on. It allow us to focus on the problems we are trying to solve instead of debating over the visual esthetics of the code. After all we are not painting an impressionist art, but rather crafting solutions for real world problems like generating memes.
Prettier comes with great defaults but we like to tweak them in order to match our own preferences. There are a couple of ways you can configure prettier (it is using cosmicconfig) but in our case we want to keep it simple by just adding the following key in our package.json
:
"prettier": {
"singleQuote": true,
"trailingComma": "all"
}
In addition to configuring our editors to support Prettier we also like to have a simple command that can be run in order to format the whole codebase. It is a one-liner that can be added to the scripts
section:
"scripts": {
...
"format": "prettier --write \"src/**/*.{js,json}\"",
...
},
Configuring linters
Create React App comes with ESLint support out of the box. The default configuration comes from the react-app package which has a good set of basic and accessibility rules defined. We would like to extend those with some React-specific rules recommended from eslint-plugin-react.
We better install the all new and fresh react-hooks ESLint plugin as we are going to be using React Hooks. Hopefully Create React App will include it by default because everyone is using hooks nowadays (mainly for demos or funny blog posts).
Here is the final linting configuration from our package.json
:
"eslintConfig": {
"extends": [
"react-app",
"plugin:react/recommended"
],
"settings": {
"react": {
"version": "detect"
}
},
"plugins": [
"react-hooks"
],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}
Additionally you can include other awesome ESLint plugins for React Native, lodash or GraphQL. You can even include a unicorn 🦄.
Finally we are going to add a new script for running the linter:
"scripts": {
...
"lint": "eslint src",
...
},
Installing pre-commit hooks
The final step of the bootstrapping process is to install pre-commit hooks so that all configured tools will be run automatically before committing. We are going to call out for help from our good friends husky and lint-staged.
Here is our lint-staged
configuration in packages.json
:
"lint-staged": {
"src/**/*.js": [
"prettier --write",
"eslint --max-warnings=0",
"yarn test --bail --findRelatedTests",
"git add"
],
"src/**/*.{json}": [
"prettier --write",
"git add"
]
}
A couple of things to note here:
- We are running
eslint
with--max-warnings=0
option that specifies that we will treat warnings as errors. It is a nice trick if you are integrating new ESLint plugins and you want to gradually integrate their rules in your code base. - We are running Jest tests with
--findRelatedTests
option which will find and execute just the tests related to our changes. That's another neat trick you can use if you have large test suites.
Finally we need to let the dogs out and configure husky
to lint-stage
before committing:
"husky": {
"hooks": {
"pre-commit": "cross-env CI=true lint-staged"
}
},
You might be wondering why we are setting CI=true
. Jest (our test runner) checks it under the hook and disables its default watch mode when it's present. Pure magic.
We are also using cross-env in case Apple continues its downfall with MacBooks and we have to switch to Windows.
At that point you should be hooked. There are React Hooks, Git Hooks...
It's hooks all the way down.
Styling the meme generator app
Once the app has been bootstrapped comes another mission-critical decision we have to make: What we are going to use for styling?
We have been successfully using Semantic UI, Material UI, Bootstrap all along with CSS modules. We have even used JSS in one of our projects.
This time we decided to be even more hipster and go with styled-components <💅>
. We liked the simplicity of Rebass' primitive components and we were quickly sold hooked.
Having never used styled-components
before we can definitely say that it is a very good alternative that you should be checking out. Especially if you are building your own design system (style guide) from scratch.
Another benefit of using Rebass is that it is built on top of styled-system and is easily themeable. One of the highest priority requirements for our app was to have a dark theme, because memes are usually generated outside of working hours.
You can check our light
and dark
theme definitions in this file. In the future we might even want to support prefers-color-scheme: dark
media query. You can try to find the hidden toggle (it's an Easter egg) that can be used to switch between the themes in our final app.
Implementing the meme generator app
We are approaching the mid of the post but haven't used React Hooks yet. It's time to "enter the codebase and do whatever we want".
Adding meme labels
The essence of every meme generator is having top and bottom labels that can be changed from some inputs. Let see how can do that using Hooks:
const onLabelChange =
setter =>
({ target: { value } }) =>
setter(value);
const App = () => {
const [topLabel, setTopLabel] = useState('Do the most meaningful meme...');
const [bottomLabel, setBottomLabel] = useState('...of your life');
const [imageSrc, setImageSrc] = useState(getRandomMeme());
return (
<Card variant="primary" mx="auto" my={3} pt={3}>
<Flex flexDirection="column" px={3} pt={3}>
<Input
variant="primary"
placeholder="Top text"
value={topLabel}
onChange={onLabelChange(setTopLabel)}
/>
<Input
variant="primary"
placeholder="Bottom text"
value={bottomLabel}
onChange={onLabelChange(setBottomLabel)}
/>
</Flex>
<Meme {...{ topLabel, bottomLabel, imageSrc }} />
</Card>
);
};
🎉Congrats. That's your first usage of React Hooks right there. Good news is that they work as promised. We are storing the labels' state via useState
and then passing the values to the <Meme />
component.
A neat trick to remove code duplication is the extraction of the change handler logic into a new higher order function. It destructures the changed value from the input and passes it to the state setter function.
Now let's look at the <Meme />
component.
Visualizing the meme
There are a number of ways to implement the actual meme component including DOM elements, Canvas or SVG. Taking into a account that we have to export an image for the meme and we have to support all browser eliminates the DOM elements option (dom-to-image does not support Safari). Canvas solution was eliminated as well due to its imperative API which does not fit well into React's declarative nature. And we are left with the SVG as the only viable option.
Using SVG comes with its own set of problems (sounds like choosing to use Regular Expressions).
The first issue we knew about was that there is no way to wrap text. It's coming in SVG 2.0 but for now we have to use the <foreignObject />
trick in order to insert some DOM elements into the SVG itself.
The second issue comes from the fact that SVG is not using a CSS like box model, but rather its own canvas-like positioning system. Combining that with the fact that we have to display raster images and we end up in a situation where we have to dynamically adjust the <svg />
width and height based on the image's dimensions. Thankfully there is a hook for doing this: the almighty useEffect
.
Let's look at the code of the <Meme />
component.
const MemeText = styled(Text)`
position: absolute;
width: 100%;
bottom: ${props => (props.verticalAlign === 'bottom' ? 0 : undefined)};
color: white;
font-family: 'Impact', 'Oswald', sans-serif;
font-weight: 700;
text-transform: uppercase;
`;
MemeText.defaultProps = {
verticalAlign: 'top',
};
const Meme = ({ imageSrc, topLabel, bottomLabel }) => {
const [dimensions, setDimensions] = useState({});
useEffect(() => {
const image = new Image();
image.onload = () => {
const { width, height } = image;
setDimensions({ width, height });
};
image.src = imageSrc;
}, [imageSrc]);
return (
<svg {...dimensions}>
<image xlinkHref={imageSrc} width="100%" height="100%" />
<switch>
<foreignObject width="100%" height="100%">
<MemeText>{topLabel}</MemeText>
<MemeText verticalAlign="bottom">{bottomLabel}</MemeText>
</foreignObject>
</switch>
</svg>
);
};
Our hook logic is simple: once the image is loaded we take its width and height and pass them to the SVG. As loading images is an async operation we need to store the dimensions in the state. The key thing in using state and effect hooks in combination is specifying your dependencies (the second array argument of useEffect
) otherwise you will end up in an infinite recursion. The effect is causing a re-render because it updates the state, but the render is causing a new effect and 💥. In our case, we want the effect to kick in only when the image source is changed.
Another interesting part of this code snippet is the <MemeText />
component. It is a styled-component that has a custom property verticalAlign
used to donate whether it's the top or the bottom label. The usage of tagged template literals really fits well into the styled-components' philosophy.
This example is simplified in order to be easily comprehendible. The actual code is a little bit more involved because it supports maximum width for the SVG element in case the image is large.
Exporting the meme
Turning SVGs into raster graphics is as simple as installing the save-svg-as-png
and calling its saveSvgAsPng
function. The only interesting part is that the API expect an SVG DOM element as its first argument. In React words it's ref
time, but unsurprisingly there is a hook for that as well: useRef
.
We need to have the ref in the <App />
component so we can pass it to the saveSvgAsPng
function. Here is the relevant part of the code:
const App = () => {
//...
const ref = useRef(null);
return (
<Card variant="primary" mx="auto" my={3} pt={3}>
<Button
variant="primary"
onClick={() => saveSvgAsPng(ref.current, 'meme.png')} title="Download image"
>
Download
</Button>
...
<Meme {...{ topLabel, bottomLabel, imageSrc, ref }} />
</Card>
);
};
Now let's go to the <Meme />
component to see how are we going to handle the ref:
const Meme = ({ imageSrc, topLabel, bottomLabel, forwardedRef }) => {
//...
return (
<svg {...dimensions} ref={forwardedRef}> ...
</svg>
);
};
const MemeWithRef = forwardRef((props, ref) => (
<Meme {...props} forwardedRef={ref} />));
We use the ref forwarding concept from React and we forwardRef
the passed ref from the <App />
to the <Meme />
and down to the <svg />
itself.
There are even more hooks in the final version of the code because it handles responsive layout via useMedia
hook.
Along with hooks, there is a neat trick that dynamically requires all meme images from the /memes
folder, but let's leave all that for the curious reader to pursue in the GitHub repo.
Testing the meme generator app
Every well-crafted software should be guarded by well-crafted tests.
We are fans of radical unit testing which means having 100% code coverage. After all, why you have written a given piece of code if it is not being exercised? If you do not want to test (or can't for that matter) given part of your code you better be explicit about it.
I wanted to be clear that 100% code coverage is not an end in itself. It's just a great tool to force us to think three times before writing any piece of production code.
Configuring the test runner
Create React App comes with Jest support out of the box and Jest is our preferred JavaScript testing framework. Apart from having a fast and reliable test runner being able to use snapshots is a great way to test your React components.
We are going to extend the default setup by installing Enzyme, along with:
jest-enzyme
- provides some nice assertions on top of Enzyme (toExist()
,toIncludeText()
, etc.).jest-styled-components
- provides styled-components specific snapshot testing and assertion (toHaveStyleRule()
)
Here is our testing configuration in package.json
:
"jest": {
"snapshotSerializers": [
"enzyme-to-json/serializer"
],
"collectCoverageFrom": [
"src/**/*.js"
],
"coverageThreshold": {
"global": {
"branches": 100,
"functions": 100,
"lines": 100,
"statements": 100
}
}
}
And our setupTests.js
(which comes from Create React App):
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import 'jest-enzyme';
import 'jest-styled-components';
configure({ adapter: new Adapter() });
Writing the tests
One of the interesting challenges while writing the tests was how to mock the side-effect of loading images and reading their dimensions within the <Meme />
component. Create React App configures Jest with jsdom
which yields that we cannot load images like we would normally do in a browser.
Nevertheless there is a way to mock this on your own. If you remember our hook was reading an image's width and height once it is being loaded and that's exactly what we are going to mock:
// dimensions variable defined
const spy = jest
.spyOn(Image.prototype, 'src', 'set')
.mockImplementationOnce(function () {
this.width = dimensions.width;
this.height = dimensions.height;
this.onload();
});
We are spying on the Image
prototype object and when the src
property is being set we assign the width and height of the image and call its onload
handler.
In the mock implementation we are not using an arrow function in order to be able to access the correct this
- the one that points to the Image
object being mocked.
Reporting code coverage results
Running yarn test
will run Jest in watch mode that does not report code coverage results by default. In order to tell the runner we need coverage we have to pass --coverage
switch to the command:
yarn test --coverage
Jest bundles Istanbul as code coverage tool so we get a nice lines covered report after running the command in ./coverage/lcov-report/
.
Integrating this with online tools for code coverage is even easier. Let's look at our final ci
script that lints, tests with coverage and finally reports the coverage results to Codecov:
"scripts": {
...
"ci": "yarn lint && cross-env CI=true yarn test --coverage && yarn codecov",
...
},
This command is run on every CI build. But what do our CI and CD pipelines look like?
Deploying the meme generator app
The beauty of client-side only apps is that you can literally deploy them everywhere including 5$ IoT boards that can serve HTTP requests.
We decided to go with S3 and CloudFront because our site has already been using it and we can leverage some of the existing infrastructure like setting up SSL for the CloudFront distribution.
I'm not going to go over the process of setting up a S3 bucket and CloudFront distribution as it is already well documented but rather focus on some interesting challenges that we face due to the fact that the app should be hosted in a subfolder: st6.io hosts our website (which is a React app built with Gatsby) and st6.io/meme should host the meme generator.
Configuring for subfolder hosting
The first thing we have to do is to tell Create React App that it should build the app with relative paths. Let's do that by specifying the homepage
in package.json
:
"homepage": "https://st6.io/meme"
The second thing comes from the fact that we are reusing the same CloudFront distribution for both our site and the meme generator. CloudFront does support this but it requires configuring a second origin (in our case the meme generator S3 bucket configured for static site hosting) and adding a behavior to point to that origin. The cache behavior needs the following path pattern: meme*
. That tells the CDN to reroute all requests to st6.io/meme*
to our meme generator. There is one more caveat here and it comes from the fact that when the request is rerouted it will search for ./meme/resource-name
in our S3 bucket. This means that we have to upload our build artefact (static files) in a meme
folder inside the bucket.
The third thing to handle is the CDN invalidation once the bucket is being updated. It is a one-line command using the AWS CLI but there is a npm package just for this so we decided to go with it in order to not require the AWS CLI pre-installed.
Here is our Travis CI configuration file:
language: node_js
cache: yarn
install:
- yarn global add travis-ci-cloudfront-invalidation
- yarn
script:
- yarn ci || travis_terminate 1
- yarn build
deploy:
provider: s3
access_key_id: $AWS_ACCESS_ID
secret_access_key: $AWS_SECRET_ID
bucket: st6-meme
upload_dir: meme
local_dir: build
skip_cleanup: true
on:
branch: master
after_deploy:
- travis-ci-cloudfront-invalidation -a $AWS_ACCESS_ID -s $AWS_SECRET_ID -c $CF_DISTRIBUTION_ID -i '/meme/*' -b $TRAVIS_BRANCH -p $TRAVIS_PULL_REQUEST
Some things to note from it:
- We are not specifying the Node.js version. Travis is smart enough to read it from the
.nvmrc
file. - We haven't added the
travis-ci-cloudfront-invalidation
package as an app devDependency. We want to leave the deployment specifics outside of the app's configuration. - We are specifying the S3
upload_dir
tomeme
in order to handle the above mentioned caveat. - We are telling CloudFront to issue an invalidation only for
'/meme/*'
resources in order to speed things up.
Do the most meaningful meme of your life
We are nearing the end of our journey. We started with an ambitious goal to craft a meme generator and ended up with an app deployed at https://st6.io/meme.
There is one more thing we need to do before starting the promotion of the app with silly blog posts. We need to add badges to the readme, because
Every craftsman signs his
workcraft.
We are going to use the great service at Badgen to generate some cool badges and add them to our readme:
... and with that, we can declare "Let there be memes!".
It's time to do the most meaningful meme of your life!
You can read the next post in the ST6 meme generator series here.
Looking to join the culture you've been dreaming about? Ready to take the red pill? Want to have more fun with React Hooks? Come and meet us.