How to Build a Real-time Collaborative Markdown Editor with React Hooks, GraphQL & AWS AppSync

This post will walk through everything from creating the API, writing the client code, & deploying a custom domain.
Try it out — writewithme.dev
Check out the code- https://github.com/dabit3/write-with-me

I’ve been deep-diving into some interesting use-cases showing some of the real-time capabilities of GraphQL subscriptions.

A couple of weeks ago I built HypeBeats, a collaborative drum-machine with the help of Ken Wheeler’s drum machine, & before that I built a real-time drawing canvas with React Canvas Draw.

Last week I sent out a tweet to see what other ideas people had, & there were a lot:

An idea that immediately stood out to me was from NagarajanFs on Twitter:

Basically his idea was Google Docs but for markdown. I loved it, so I decided to build it! The result was writewithme.dev.


Getting Started

The first thing I did was created a new React application:

npx create-react-app write-with-me

The next thing I needed to do was to find the tool I was going to use to allow markdown in a React App. I stumbled upon react-markdown by Espen Hovlandsdal (rexxars on Twitter).

npm install react-markdown

React Markdown was super simple to use. You import ReactMarkdown from the package & pass in the markdown you’d like to render as a prop:

const ReactMarkdown = require('react-markdown')

const input = '# This is a header\n\nAnd this is a paragraph'

<ReactMarkdown source={input} />

Building the API

Now that we have the markdown tool the next set was creating the API. I used AWS AppSync & AWS Amplify to do this. With the Amplify CLI, you can set a base type & add a decorator to build out the entire backend by taking advantage of the GraphQL Transform library.

amplify init
amplify add api
// chose GraphQL
// Choose API Key as the authentication

The schema I used was this:

type Post @model {
id: ID!
clientId: ID!
markdown: String!
title: String!
createdAt: String
}
The @model decorator will tell Amplify to deploy a GraphQL backend complete with a DynamoDB data source & schema (types & operations) for all crud operations & GraphQL subscriptions. It will also create the resolvers for the created GraphQL operations.

After defining & saving the schema we deploy the AppSync API:

amplify push

When we run amplify push, we’re also given the option to execute GraphQL codegen in either TypeScript, JavaScript, or Flow. Doing this will introspect your GraphQL schema & automatically generate the GraphQL code you’ll need on the client in order to execute queries, mutations, & subscriptions.

Installing the dependencies

Next, we install the other necessary libraries we need for the app:

npm install aws-amplify react-router-dom uuid glamor debounce
  • uuid — to create unique IDs to uniquely identify the client
  • react-router-dom — to add routing
  • aws-amplify — to interact with the AppSync API
  • glamor — for styling
  • debounce — for adding a debounce when user types

Writing the code

Now that our project is set up & our API has been deployed, we can start writing some code!

The app has three main files:

  • Router.js — Defines the routes
  • Posts.js — Fetches & renders the posts
  • Post.js — Fetches & renders a single post

Router.js

Setting up navigation was pretty basic. We needed two routes: one for listing all of the posts & one for viewing a single post. I decided to go with the following route scheme:

/post/:id/:title

When someone hits the above route, we have access to everything we need to display a title for the post as well as the ID for us to fetch the post if it is an existing post. All of this info is available directly in the route parameters.

React Hooks with GraphQL

If you’ve ever wondered how to implement hooks into a GraphQL application, I recommend also reading my post Writing Custom Hooks for GraphQL because that’s exactly what we’ll be doing.

Instead of going over all of the code for the 2 components (if you’d like to see the code, click on the links above), I’d like to focus on how we implemented the necessary functionality using GraphQL & hooks.

The main API operations we needed from this app are intended to do the following:

  1. Load all of the posts from the API
  2. Subscribe to new posts being created by others
  3. Load an individual post
  4. Subscribe to changes within an individual post & re-render the component

Let’s take a look at each.

Loading all of the posts from the API

To load the posts I went with a useReducer hook combined with a function call within a useEffect hook. We first define the initial state. When the component renders for the first time we call the API & update the state using the fetchPosts function. This reducer will also handle our subscription:

import { listPosts } from './graphql/queries'
import { API, graphqlOperation } from 'aws-amplify'
const initialState = {
posts: [],
loading: true,
error: false
}
function reducer(state, action) {
switch (action.type) {
case 'fetchPostsSuccess':
return { ...state, posts: action.posts, loading: false }
case 'addPostFromSubscription':
return { ...state, posts: [ action.post, ...state.posts ] }
case 'fetchPostsError':
return { ...state, loading: false, error: true }
default:
throw new Error();
}
}
async function fetchPosts(dispatch) {
try {
const postData = await API.graphql(graphqlOperation(listPosts))
dispatch({
type: 'fetchPostsSuccess',
posts: postData.data.listPosts.items
})
} catch (err) {
console.log('error fetching posts...: ', err)
dispatch({
type: 'fetchPostsError',
})
}
}
// in the hook
const [postsState, dispatch] = useReducer(reducer, initialState)
useEffect(() => {
fetchPosts(dispatch)
}, [])

Subscribing to new posts being created

We already have our state ready to go from the above code, now we just need to subscribe to the changes in the hook. To do that, we use a useEffect hook & subscribe to the onCreatePost subscription:

useEffect(() => {
const subscriber = API.graphql(graphqlOperation(onCreatePost)).subscribe({
next: data => {
const postFromSub = data.value.data.onCreatePost
dispatch({
type: 'addPostFromSubscription',
post: postFromSub
})
}
});
return () => subscriber.unsubscribe()
}, [])

In this hook, we set up a subscription that will fire when a user creates a new post. When the data comes through, we call the dispatch function & pass in the new post data that was returned from the subscription.

Fetching a single post

When a user lands on a route, we can use the data from the route params to identify the post name & ID:

https://www.writewithme.dev/#/post/9999b0bb-63eb-4f5b-9805-23f6c2661478/%F0%9F%94%A5%20Write%20with%20me

In this route, the ID would be 9999b0bb-63eb-4f5b-9805–23f6c2661478 & the name would be Write with me.

When the component loads, we extract these params & use them.

The first thing we do is attempt to create a new post. If this is successful, we are done. If this post already exists, we are given the data for this post from the API call.

This may seem strange at first, right? Why not try to fetch, & if unsuccessful the create? Well, we want to reduce the total number of API calls. If we attempt to create a new post & the post exists, the API will actually return the data from the existing post allowing us to only make a single API call. This data is available in the errors:

err.errors[0].data

We handle the state in this component using useReducer hook.

// initial state
const post = {
id: params.id,
title: params.title,
clientId: CLIENTID,
markdown: '# Loading...'
}
function reducer(state, action) {
switch (action.type) {
case 'updateMarkdown':
return { ...state, markdown: action.markdown, clientId: CLIENTID };
case 'updateTitle':
return { ]...state, title: action.title, clientId: CLIENTID };
case 'updatePost':
return action.post
default:
throw new Error();
}
}
async function createNewPost(post, dispatch) {
try {
const postData = await API.graphql(graphqlOperation(createPost, { input: post }))
dispatch({
type: 'updatePost',
post: {
...postData.data.createPost,
clientId: CLIENTID
}
})
} catch(err) {
if (err.errors[0].errorType === "DynamoDB:ConditionalCheckFailedException") {
const existingPost = err.errors[0].data
dispatch({
type: 'updatePost',
post: {
...existingPost,
clientId: CLIENTID
}
})
}
}
}
// in the hook, initialize the state
const [postState, dispatch] = useReducer(reducer, post)
// fetch post
useEffect(() => {
const post = {
...postState,
markdown: input
}
createNewPost(post, dispatch)
}, [])

Subscribing to a post change

The last thing we need to do is subscribe to changes in a post. To do this, we do two things:

  1. Update the API when the user types (both title or markdown changes)
  2. Subscribe to changes then the post is updated
async function updatePost(post) {
try {
await API.graphql(graphqlOperation(UpdatePost, { input: post }))
console.log('post has been updated!')
} catch (err) {
console.log('error:' , err)
}
}
function updateMarkdown(e) {
dispatch({
type: 'updateMarkdown',
markdown: e.target.value,
})
const newPost = {
id: post.id,
markdown: e.target.value,
clientId: CLIENTID,
createdAt: post.createdAt,
title: postState.title
}
updatePost(newPost, dispatch)
}
function updatePostTitle (e) {
dispatch({
type: 'updateTitle',
title: e.target.value
})
const newPost = {
id: post.id,
markdown: postState.markdown,
clientId: CLIENTID,
createdAt: post.createdAt,
title: e.target.value
}
updatePost(newPost, dispatch)
}
useEffect(() => {
const subscriber = API.graphql(graphqlOperation(onUpdatePost, {
id: post.id
})).subscribe({
next: data => {
if (CLIENTID === data.value.data.onUpdatePost.clientId) return
const postFromSub = data.value.data.onUpdatePost
dispatch({
type: 'updatePost',
post: postFromSub
})
}
});
return () => subscriber.unsubscribe()
}, [])

When the user types, we update both the local state as well as the API. In the subscription, we first check to see if the subscription data coming in is from the same Client ID. If it is, we do nothing. If it is from another client, we update the state.

Deploying the app on a custom domain

Now that we’ve built the app, what about deploying it to a custom domain like I did with writewithme.dev?

I did this using GoDaddy, Route53 & the Amplify console.

In the AWS dashboard, go to Route53 & click on Hosted Zones. Choose Create Hosted Zone. From there, enter your domain name & click create.

Be sure to enter your domain name as is, without www. E.g. writewithme.dev

Now in Route53 dashboard you should be given 4 nameservers. In your hosting account, set these custom nameservers in your DNS setting for the domain you’re using.

These nameservers should look something like ns-1355.awsdns-41.org, ns-1625.awsdns-11.co.uk, etc…

Next, in the Amplify Console, click on Get Started under the Deploy section.

Connect your GitHub account & then choose the repo & branch that your project lives in.

This will walk you through deploying the app in the Amplify Console & making it live. Once complete, you should see some information about the build & some screenshots of the app:

Next, click Domain Management in the left menu. Next, click the Add Domain button.

Here, the dropdown menu should show you the domain you have in Route53. Choose this domain & click Configure domain.

This should deploy the app to your domain (this will take between 5–20 minutes). The last thing is to set up redirects. Click on Rewrites & redirects.

Make sure the redirect for the domain looks like this (i.e. redirect the https://websitename to https://www.websitename):

Next Steps

The next thing I’d like to do would be to add authentication & commenting functionality (so people can comment on each individual post). This is all doable with Amplify & AppSync. If we wanted to add authentication, we could add it with the CLI:

amplify add auth

Next, we’d have to write some logic to log people in & out or we can use the withAuthenticator HOC. To learn more about how to do this, check out these docs.

Next, we’d probably want to update the schema to add commenting functionality. To do so, we could update the schema to something like what I created here. In this schema, we’ve added a field for the Discussion (in our case it would probably be called Comment).

In the resolver, we could probably also correlate a user’s identity to the message as well but using the $context.identity.sub which would be available after the user is signed in.

Check out the final code- https://github.com/dabit3/write-with-me

My Name is Nader Dabit . I am a Developer Advocate at AWS working with projects like AWS AppSync and AWS Amplify.
I’m also the author of React Native in Action, & the editor of React Native Training & OpenGraphQL.