Build a Realtime Voting App with NextJS and Ably
Learn some key features of NextJS, session management and real-time communication in this extensive guide
In this article, I will show you how to use Ably (a real-time communication platform) to create an app where users can create their polls and share them. And we'll use MongoDB to store all of the poll data.
The demo below shows the application in action.
Prerequisites
Setting Up
Create a new Next.js project with Typescript, then install the following packages:
npm i @ably-labs/react-hooks ably axios chart.js connect-redis date-fns ioredis jotai mongoose react-chartjs-2 react-feather next-session
If that seemed like a lot, here's the breakdown:
@ably-labs/react-hooks
- Provides a React hook interface to simplify operations with Ablyably
- Client library for the Ably Realtime and REST APIsaxios
- Easy-to-use HTTP clientchart.js
- Flexible charts library, wrapped for React withreact-chartjs-2
connect-redis
- Creates a session store wrapper around a Redis instancemongoose
- Object modelling for MongoDB, includes schema validation, type casting and query building helpersioredis
- A performant Redis client for Node.jsjotai
- Simple state management in Reactreact-feather
- Provides React wrappers for icons from Feather
Now make sure that secrets such as the Ably API key and MongoDB connection URI are stored in a .env.local
file.
ABLY_API_KEY=******
MONGODB_URI=******
Creating a DB Model
Mongoose models your MongoDB documents as models. When models are created, data is validated against a schema. A schema can define a document's properties as well as its data types. Mongoose schemas can also use other validators such as length, enum checking and regex matching.
For our voting app, the only model we need is a Poll. Let's create a file named models/Poll.ts
and create the schema.
import mongoose, { InferSchemaType } from "mongoose";
const { Schema } = mongoose;
const pollSchema = new Schema({
creator: { type: String, required: true },
title: { type: String, required: true },
// `results` contains current votes i.e. {[candidate: string]: string[]}
// , where the array value represents the voters
results: { type: Map, of: [String], required: true },
privacy: { type: Boolean, required: true, default: false },
end: { type: Date, required: true },
});
// Creates a type interface from the given schema
// The _id field is needed to identify any given document
export type Poll = InferSchemaType<typeof pollSchema> & { _id: string };
// Uses types which can be serialised into Next.js page props
export type PollPrimitive = Omit<Omit<Poll, "end">, "results"> & {
end: string;
results: { [key: string]: string[] };
};
// Avoids re-initialisation of the model class
export default mongoose.models.Poll || mongoose.model("Poll", pollSchema);
Connecting to the Database
Create a new folder inside the project named lib
. Within this folder, create a file named dbConnect.ts
. The file will export a function to return a cached connection to the database.
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) {
throw new Error(
"Please define the MONGODB_URI environment variable inside .env.local"
);
}
/**
* Global is used here to maintain a cached connection across hot reloads
* in development. This prevents connections growing exponentially
* during API Route usage.
*/
declare global {
var mongoose: {
conn: typeof import("mongoose") | null;
promise: Promise<typeof import("mongoose")> | null;
};
}
let cached = global.mongoose;
if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
async function dbConnect() {
if (!MONGODB_URI) return;
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
};
cached.promise = mongoose.connect(MONGODB_URI, opts);
}
try {
cached.conn = await cached.promise;
} catch (e) {
cached.promise = null;
throw e;
}
return cached.conn;
}
export default dbConnect;
Our app will consist of the following page routes:
/
- Listing all polls/[id]
- Viewing specific poll/[id]/edit
- Editing specific poll/new
- Creating a poll
and uses these API routes:
/api/ably-token
- Generate a token request for authentication with Ably/api/polls
(POST) - Create a poll/api/polls/[id]
(PUT, DELETE) - Modify a poll/api/vote
(POST) - Add the current user to the given poll option
To begin, we'll get started with the above API routes.
API Routes
Sessions
To simplify our application, each user will be represented by their session id instead of a traditional username-password solution. Although it won't be possible for users to keep the same session id forever, the advantage is that no authentication process is involved. Therefore, using the application will be more convenient.
In the lib
folder, create a new file named getSession.ts
. Remember Redis? Well, we use it here to store session data.
import nextSession from "next-session";
import { expressSession, promisifyStore } from "next-session/lib/compat";
import RedisStoreFactory from "connect-redis";
import Redis from "ioredis";
const RedisStore = RedisStoreFactory(expressSession);
export const getSession = nextSession({
autoCommit: false,
store: promisifyStore(
new RedisStore({
client: new Redis(),
})
),
});
Also, make sure to set autoCommit
to false
. When set to true
, next-session
only saves the session if it changed, but we will only access the id and make no changes. Therefore, we have to call await session.commit()
whenever we access the session.
/ably-token
Here we initialise a client object for the Ably REST API. Then (in the route) the client id is set to the session id, and the token request is sent to the client.
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "lib/getSession";
import Ably from "ably/promises";
const rest = new Ably.Rest(process.env.ABLY_API_KEY as string);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getSession(req, res);
await session.commit();
const tokenParams = {
clientId: session.id,
};
const tokenRequest = await rest.auth.createTokenRequest(tokenParams);
res.status(200).json(tokenRequest);
}
/polls
In this API route, we will take in data provided through the POST request to construct a new Poll
model. As specified before, the creator of the Poll
will be the session id.
import type { NextApiRequest, NextApiResponse } from "next";
import Poll from "models/Poll";
import { getSession } from "lib/getSession";
import dbConnect from "lib/dbConnect";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
res.status(405).end(`Method ${req.method} Not Allowed`);
return;
}
await dbConnect();
const session = await getSession(req, res);
await session.commit();
const { title, options, end, privacy } = req.body;
const poll = new Poll({
creator: session.id,
title: title,
results: options.map((option: string) => [option, []]),
end: end && new Date(end),
privacy: privacy,
});
await poll.save();
res.status(200).json(poll);
}
/polls/[id]
When updating or deleting a poll, a client must send a request to this API route. We need to first check whether the specified Poll
exists. Then we check whether the user had created the Poll
.
After the poll is updated, a message is published to the Ably channel. The message contains the updated information. This is so that changes for properties such as the poll's title can be viewed in real-time.
import type { NextApiRequest, NextApiResponse } from "next";
import Ably from "ably/promises";
import Poll from "models/Poll";
import { getSession } from "lib/getSession";
import dbConnect from "lib/dbConnect";
const rest = new Ably.Rest(process.env.ABLY_API_KEY as string);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await dbConnect();
const session = await getSession(req, res);
await session.commit();
const { id } = req.query;
let poll = await Poll.findById(id).exec();
if (!poll) {
res.status(404).send("Cannot find poll at requested ID");
return;
}
if (poll.creator !== session.id) {
res.status(403).end("Unauthorized access to poll");
return;
}
switch (req.method) {
case "PUT":
const { title, options, end, privacy } = req.body;
// Any new options will have empty arrays for their voters,
// but existing options requested will be overwritten
// by their previous value
let results = Object.fromEntries(
options.map((option: string) => [option, []])
);
results = { ...results, ...Object.fromEntries(poll.results) };
poll.title = title;
poll.results = results;
poll.end = new Date(end);
poll.privacy = privacy;
// Save the updated document and return it
poll = await poll.save();
const channel = rest.channels.get(`polls:${id}`);
await channel.publish("update-info", poll);
res.status(200).end();
break;
case "DELETE":
await poll.delete();
res.status(200).end();
break;
default:
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
/vote
When a user picks an option, this API route first checks the option is valid. Then it proceeds to add the session id to the option's voters array if the user hasn't voted already.
import type { NextApiRequest, NextApiResponse } from "next";
import Ably from "ably/promises";
import Poll from "models/Poll";
import { getSession } from "lib/getSession";
import dbConnect from "lib/dbConnect";
const rest = new Ably.Rest(process.env.ABLY_API_KEY as string);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
res.status(405).end(`Method ${req.method} Not Allowed`);
return;
}
await dbConnect();
const session = await getSession(req, res);
await session.commit();
const { id, option } = req.body;
const poll = await Poll.findById(id).exec();
if (!poll) {
res.status(404).send("Cannot find poll at requested ID");
return;
}
const results = Object.fromEntries(poll.results);
if (!Object.keys(results).includes(option)) {
res.status(400).json(`Invalid option "${option}"`);
return;
}
let voters: string[];
for (let candidate of Object.keys(results)) {
voters = results[candidate];
if (voters.includes(session.id)) {
// Already voted, stop from voting again
res.status(403).json(`Already voted for "${candidate}"`);
return;
}
}
results[option].push(session.id);
poll.results = results;
await poll.save();
// Publish update to the channel
const channel = rest.channels.get(`polls:${id}`);
channel.publish("update-votes", results);
res.status(200).end();
}
Notice how we must call session.commit()
every time we need the session. How could you remove this redundancy?
Pages
_app
Before we do anything else, we have to make sure we have configured Ably for the client. We also need to register the elements needed to display charts.
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { configureAbly } from "@ably-labs/react-hooks";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
} from "chart.js";
ChartJS.register(CategoryScale, LinearScale, BarElement);
// Change this URL if necessary
configureAbly({ authUrl: "http://localhost:3000/api/ably-token" });
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
index (/)
On the listing page, we need to fetch the data for all the polls. The polls requested are filtered to either be public or from the poll creator. Before returning the page props, the data must be serialized to primitive data types.
import dbConnect from "lib/dbConnect";
import { getSession } from "lib/getSession";
import Poll, { PollPrimitive } from "models/Poll";
import { GetServerSideProps, NextPage } from "next";
const Home: NextPage<{ polls: PollPrimitive[] }> = ({ polls }) => {
// ...
return <div />;
};
export const getServerSideProps: GetServerSideProps = async ({
req,
res,
query,
}) => {
// If `personal` is "true", filter by only the current user's polls
const { personal } = query;
const session = await getSession(req, res);
await session.commit();
await dbConnect();
const filter =
personal === "true" ? { creator: session.id } : { privacy: false };
// Serialize ObjectId and Date to string
const result = await Poll.find(filter);
const polls = result.map((doc) => {
const poll = doc.toJSON();
poll._id = poll._id.toString();
poll.end = poll.end.toString();
return poll;
});
return {
props: { polls },
};
};
export default Home;
In the page component, we display all the polls available, or a message if no polls exist. Also, make sure that TailwindCSS is configured for this NextJS project.
import AppBar from "components/AppBar";
import PollDisplay from "components/PollDisplay";
import dbConnect from "lib/dbConnect";
import { getSession } from "lib/getSession";
import Poll, { PollPrimitive } from "models/Poll";
import { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import { Terminal } from "react-feather";
const Home: NextPage<{ polls: PollPrimitive[] }> = ({ polls }) => {
return (
<>
<Head>
<title>VotR</title>
<meta
name="description"
content="Generated by create next app"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<AppBar />
<main className="p-3">
{polls.length === 0 && (
<div className="h-80 w-full flex flex-col justify-center items-center space-y-5 text-2xl font-semibold">
<h3>No polls created.</h3>
<Terminal className="w-9 h-9" />
<h3>Create one now!</h3>
</div>
)}
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{polls.map((poll) => (
<PollDisplay key={poll._id} data={poll} />
))}
</div>
</main>
</>
);
};
// ...
Now create a new folder named components
with its first file, AppBar.tsx
. This component creates a button to change the poll filter and a button to create a poll.
Add the following code to the file:
import { useRouter } from "next/router";
import React from "react";
import { Plus, Filter } from "react-feather";
export default function AppBar() {
const router = useRouter();
const viewingPersonal = router.query.personal === "true";
const openYourPolls = () => {
const newUrl = `/?personal=${(!viewingPersonal).toString()}`;
router.replace(newUrl, newUrl);
};
const openCreate = () => {
router.push("/new");
};
return (
<header className="py-6 px-5 h-20 w-full border-b-2 border-b-gray-300 flex flex-row justify-between">
<h1 className="text-3xl font-bold">VotR</h1>
<div className="flex flex-row justify-center space-x-3">
<button
onClick={openYourPolls}
type="button"
className="bg-blue-500 rounded-xl border-white border-2 h-10 px-2 text-white"
>
<Filter className="inline mr-2" />
{viewingPersonal ? "All Polls" : "Your Polls"}
</button>
<button
onClick={openCreate}
type="button"
className="bg-blue-500 rounded-xl border-white border-2 h-10 px-2 text-white"
>
<Plus className="inline mr-2" />
Create
</button>
</div>
</header>
);
}
Now create a new file named PollDisplay.tsx
in the same folder and insert the following code. This component takes poll data as a prop, and displays some basic information:
The title
How many users have voted in total
How many options are available
import { PollPrimitive } from "models/Poll";
import { useRouter } from "next/router";
import React from "react";
import { User, MapPin } from "react-feather";
export default function PollDisplay({ data }: { data: PollPrimitive }) {
const router = useRouter();
const voters = Object.values(data.results).reduce(
(acc, curr) => acc + curr.length,
0
);
const options = Object.keys(data.results).length;
const openPoll = () => {
router.push(`/${data._id}`);
};
return (
<div
onClick={openPoll}
className="mx-auto sm:m-0 rounded-xl border-gray-300 border-4 bg-slate-200 flex flex-col p-4 w-60 h-40 items-center justify-around hover:cursor-pointer"
>
<h3 className="text-xl font-semibold text-center">{data.title}</h3>
<span
className="flex flex-row"
title={`${options} options available`}
>
<MapPin className="mr-1" /> {options}
</span>
<span className="flex flex-row" title={`${voters} people voted`}>
<User className="mr-1" /> {voters}
</span>
</div>
);
}
/[id]
In this dynamic route, we fetch the poll data and return it as props, along with the session id. Create the route file in a index.tsx
file within a [id]
folder within the pages
folder.
import React from "react";
import type { GetServerSideProps, NextPage } from "next";
import { PollPrimitive } from "../../models/Poll";
import dbConnect from "lib/dbConnect";
import { getSession } from "lib/getSession";
import getPoll from "lib/getPoll";
const PollPage: NextPage<{ poll: PollPrimitive; sessionId: string }> = (
props
) => {
// ...
return <div />;
};
export const getServerSideProps: GetServerSideProps = async ({
params,
req,
res,
}) => {
const session = await getSession(req, res);
await session.commit();
await dbConnect();
const id = params?.id as string;
const poll = await getPoll(id);
if (!poll) {
return {
notFound: true,
};
}
return {
props: {
poll,
sessionId: session.id,
},
};
};
export default PollPage;
By the way, you need to create the lib/getPoll.ts
file containing the function to fetch a poll from the database.
import Poll, { PollPrimitive } from "models/Poll";
export default async function getPoll(
id: string
): Promise<PollPrimitive | null> {
const poll = await Poll.findById(id).lean();
if (!poll) {
return null;
}
poll._id = poll._id.toString();
poll.end = poll.end.toString();
return poll;
}
Moving on to the page component, here is the full list of imports for the file.
import React, { useEffect, useMemo } from "react";
import type { GetServerSideProps, NextPage } from "next";
import { PollPrimitive } from "../../models/Poll";
import dbConnect from "lib/dbConnect";
import Head from "next/head";
import { useChannel } from "@ably-labs/react-hooks";
import { useAtom } from "jotai";
import { pollAtom, sessionIdAtom } from "lib/store";
import { getSession } from "lib/getSession";
import VotesDisplay from "components/VotesDisplay";
import VotesControl from "components/VotesControl";
import { Clipboard, Edit2, Home, Trash } from "react-feather";
import { useRouter } from "next/router";
import { differenceInMinutes } from "date-fns";
import EndResult from "components/EndResult";
import getPoll from "lib/getPoll";
import axios from "axios";
Note the two atoms we are importing from lib/store
. On our page, we will create a Provider
component to initialise the values of those atoms. This means that we can share the data across components.
Use our PollPrimitve
type and jotai
to export the two atoms from lib/store.ts
:
import { atom } from "jotai";
import { PollPrimitive } from "models/Poll";
export const pollAtom = atom<PollPrimitive>({
_id: "",
creator: "",
title: "",
results: {},
privacy: false,
end: "",
});
export const sessionIdAtom = atom("");
Now add the following code to the page component:
// ...
const PollPage: NextPage<{ poll: PollPrimitive; sessionId: string }> = (
props
) => {
const router = useRouter();
const [sessionId, setSessionId] = useAtom(sessionIdAtom);
const [poll, setPoll] = useAtom(pollAtom);
const hasVoted = useMemo(() => {
for (let voters of Object.values(poll.results)) {
if (voters.includes(sessionId)) {
return true;
}
}
return false;
}, [poll, sessionId]);
useEffect(() => {
setPoll(props.poll);
setSessionId(props.sessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props]);
// ...
});
// ...
This code does three main things:
Consumes our two atoms
Computes a property to check if the user has already voted in the poll
Updates the atom values with the data from props
Now the atoms are initialised, we can listen for any updates from Ably to update the atoms again.
// ...
useChannel(`polls:${poll._id}`, "update-votes", (message) => {
setPoll((oldValue: PollPrimitive) => ({
...oldValue,
results: message.data,
}));
});
useChannel(`polls:${poll._id}`, "update-info", (message) => {
setPoll(message.data);
});
// ...
The conditional rendering achieved during rendering comes from these three variables:
const isCreator = poll.creator === sessionId;
const hasEnded = differenceInMinutes(new Date(), new Date(poll.end)) > 0;
const showDisplay = hasEnded || isCreator || hasVoted;
If
isCreator
istrue
, we display buttons to edit the poll and delete the poll, as discussed previouslyIf
hasEnded
istrue
, we present the winning option for the pollIf either is
true
orhasVoted
istrue
, we display a bar chart of the results instead of the voting options
Now return the following code from the component:
return (
<>
<Head>
<title>{poll.title}</title>
</Head>
<div className="w-full flex flex-row justify-between absolute top-0 left-0 space-x-3 p-4">
<div className="flex flex-row space-x-3">
<button
type="button"
title="Go home"
onClick={() => router.push("/")}
className="p-3 rounded-full bg-blue-300 hover:ring"
>
<Home className="text-white" />
</button>
{isCreator && (
<button
type="button"
title="Open edit page"
onClick={() => router.push(`/${poll._id}/edit`)}
className="p-3 rounded-full bg-blue-300 hover:ring"
>
<Edit2 className="text-white" />
</button>
)}
</div>
<div className="flex flex-row space-x-3">
<button
type="button"
title="Copy the link to this poll"
onClick={() =>
navigator.clipboard.writeText(window.location.href)
}
className="p-3 rounded-full bg-blue-300 hover:ring active:scale-90"
>
<Clipboard className="text-white" />
</button>
{isCreator && (
<button
type="button"
title="Delete this poll"
onClick={onDelete}
className="p-3 rounded-full bg-red-300 hover:ring ring-fuchsia-400"
>
<Trash className="text-white" />
</button>
)}
</div>
</div>
<main className="p-5 h-screen w-screen flex flex-col">
<h1 className="font-bold text-4xl text-center my-5 underline">
{poll.title}
</h1>
<div className="px-5 flex-1 flex flex-col justify-center items-center">
{showDisplay ? <VotesDisplay /> : <VotesControl />}
{hasEnded && <EndResult />}
</div>
</main>
</>
);
/[id]/edit
To change the existing poll data, this route needs to fetch the data to initially fill the form. Create this route in the pages/[id]
folder created earlier.
import React from "react";
import type { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import axios from "axios";
import { useRouter } from "next/router";
import { PollPrimitive } from "models/Poll";
import PollEditForm from "components/PollEditForm";
import dbConnect from "lib/dbConnect";
import getPoll from "lib/getPoll";
import { format } from "date-fns";
import { getSession } from "lib/getSession";
const EditPage: NextPage<{ poll: PollPrimitive }> = ({ poll }) => {
// ...
return <div />;
};
export const getServerSideProps: GetServerSideProps = async ({
params,
req,
res,
}) => {
const session = await getSession(req, res);
await session.commit();
await dbConnect();
const id = params?.id as string;
const poll = await getPoll(id);
if (!poll) {
return {
notFound: true,
};
}
if (poll.creator !== session.id) {
// Only allow the poll creator at the route
return {
redirect: {
destination: `/${id}`,
permanent: false,
},
};
}
return {
props: {
poll,
},
};
};
export default EditPage;
Now, in the page component, we format the poll data and render a form to edit the poll. Add the following code to the file:
const EditPage: NextPage<{ poll: PollPrimitive }> = ({ poll }) => {
const router = useRouter();
const id = router.query.id;
const onSubmit = async (data: any) => {
await axios.put(`/api/polls/${id}`, data);
router.push(`/${id}`);
};
const end = new Date(poll.end);
const time = format(end, "HH:mm");
const date = format(end, "yyyy-MM-dd");
const options = Object.keys(poll.results);
const title = `Votr - Editing "${poll.title}"`;
return (
<div className="w-screen h-screen bg-slate-500 overflow-x-hidden">
<Head>
<title>{title}</title>
</Head>
<main
className="
p-5 flex flex-col
justify-center items-center"
>
<h1
className="
font-bold text-4xl
text-center mb-5 text-gray-200"
>
Edit Poll
</h1>
<PollEditForm
initialData={{
title: poll.title,
options,
privacy: poll.privacy,
time,
date,
}}
onSubmit={onSubmit}
/>
</main>
</div>
);
};
/new
This route works just like the previous one, except there is no initial data fetching. Also, remember that we submit the data to a different API route.
import React from "react";
import type { NextPage } from "next";
import Head from "next/head";
import axios from "axios";
import { useRouter } from "next/router";
import PollEditForm from "components/PollEditForm";
const NewPage: NextPage = () => {
const router = useRouter();
const onSubmit = async (data: any) => {
const response = await axios.post("/api/polls", data);
const poll = response.data;
router.push(`/${poll._id}`);
};
return (
<div className="w-screen h-screen bg-slate-500 overflow-x-hidden">
<Head>
<title>Votr - New Poll</title>
</Head>
<main
className="
p-5 flex flex-col
justify-center items-center"
>
<h1
className="
font-bold text-4xl
text-center mb-5 text-gray-200"
>
New Poll
</h1>
<PollEditForm onSubmit={onSubmit} />
</main>
</div>
);
};
export default NewPage;
Poll Edit/Create Form
We use the same component to render a form for both creating and editing a poll. The difference is that when editing, we will initialise the fields with the existing data.
Create a new file named PollEditForm.tsx
in the components
folder.
This file includes the main form component, along with separate components for each field. We will start by defining the main export.
import { differenceInMinutes, format } from "date-fns";
import React, { useEffect, useRef, useState } from "react";
import { PlusSquare, XCircle } from "react-feather";
type PollData = {
title: string;
options: string[];
end: Date;
privacy: boolean;
};
interface PollEditFormProps {
onSubmit: (data: PollData) => void;
initialData?: Omit<PollData, "end"> & { time: string; date: string };
}
export default function PollEditForm(props: PollEditFormProps) {
// ...
}
The PollData
represents the values sent to the API route when submitting the form.
Now inside the component, add the following code:
// ...
const [title, setTitle] = useState(props.initialData?.title ?? "");
// At least 2 options are needed to have a poll
const [options, setOptions] = useState(
props.initialData?.options ?? ["", ""]
);
const [time, setTime] = useState(props.initialData?.time ?? "");
const [date, setDate] = useState(props.initialData?.date ?? "");
const [privacy, setPrivacy] = useState(props.initialData?.privacy ?? false);
const onSubmit = (event: React.FormEvent) => {
event.preventDefault();
const [hours, minutes] = time.split(":");
const end = new Date(
new Date(date).setHours(parseInt(hours), parseInt(minutes))
);
return props.onSubmit({ title, options, end, privacy });
};
// ...
First, state variables are created, and overridden by any initial data set in props. Next in onSubmit
, the date
and time
variables are converted into a Date
object, then the onSubmit
prop function is called.
Now add the following code to return the field components, along with a submit button.
return (
<form
onSubmit={onSubmit}
className="
bg-gray-200/30
rounded-lg
border-gray-50
border-2 px-6 py-3
text-gray-200 w-96
min-h-[500px] shadow-lg
flex flex-col
items-center space-y-4"
>
<TitleInput title={title} setTitle={setTitle} />
<DateTimeInput
date={date}
setDate={setDate}
time={time}
setTime={setTime}
/>
<OptionsInput options={options} setOptions={setOptions} />
<PrivacyInput privacy={privacy} setPrivacy={setPrivacy} />
<button
type="submit"
className="self-end rounded-2xl p-3 font-bold font-mono1 bg-gray-600 hover:bg-gray-700 focus:ring focus:border-indigo-500"
>
Submit
</button>
</form>
);
From here on out, these *Input
components simply render controlled form inputs with validation.
In approximate order of simplest to most complex:
Title Input
const TitleInput = ({
title,
setTitle,
}: {
title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>;
}) => (
<div className="flex flex-col w-full">
<label className="font-bold text-sm mb-2" htmlFor="title">
Title
</label>
<input
type="text"
id="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
className="
rounded-xl bg-transparent
focus:border-indigo-300 focus:ring
focus:ring-indigo-200 focus:ring-opacity-50"
/>
</div>
);
Privacy Input
const PrivacyInput = ({
privacy,
setPrivacy,
}: {
privacy: boolean;
setPrivacy: React.Dispatch<React.SetStateAction<boolean>>;
}) => (
<div className="flex flex-row w-full items-center">
<label htmlFor="privacy" className="font-bold text-sm mr-2">
Private
</label>
<input
type="checkbox"
id="privacy"
title="Display poll on listing page"
checked={privacy}
onChange={(event) => setPrivacy(event.target.checked)}
className="focus:ring-0 active:ring-0 focus:border-0 rounded-xl w-8 h-6"
/>
</div>
);
DateTime Input
Pay attention to the validation in the useEffect
.
const DateTimeInput = ({
date,
time,
setDate,
setTime,
}: {
date: string;
time: string;
setDate: React.Dispatch<React.SetStateAction<string>>;
setTime: React.Dispatch<React.SetStateAction<string>>;
}) => {
const timeInput = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!timeInput.current) return;
const now = new Date();
const [hours, minutes] = time.split(":");
const newDateTime = new Date(date).setHours(
parseInt(hours),
parseInt(minutes)
);
if (differenceInMinutes(newDateTime, now) < 5) {
timeInput.current.setCustomValidity(
"Time set must be at least 5 minutes from now"
);
timeInput.current.reportValidity();
} else {
timeInput.current.setCustomValidity("");
}
}, [date, time]);
return (
<div className="flex flex-col w-full">
<label className="font-bold text-sm mb-2">End Date & Time</label>
<input
required
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
min={format(new Date(), "yyyy-MM-dd")}
className="mb-2 bg-white/50 h-12 rounded-xl focus:ring-gray-400 text-gray-800"
/>
<input
required
type="time"
value={time}
onChange={(event) => setTime(event.target.value)}
ref={timeInput}
className="bg-white/50 h-12 rounded-xl focus:ring-gray-400 text-gray-800"
/>
</div>
);
};
Options Input
const OptionsInput = ({
options,
setOptions,
}: {
options: string[];
setOptions: React.Dispatch<React.SetStateAction<string[]>>;
}) => {
const removeOption = (index: number) => {
setOptions((oldOptions) => {
const newOptions = [...oldOptions];
newOptions.splice(index, 1);
return newOptions;
});
};
const updateOption = (index: number, newValue: string) => {
setOptions((oldOptions) => {
const newOptions = [...oldOptions];
newOptions.splice(index, 1, newValue);
return newOptions;
});
};
return (
<div className="flex flex-col w-full">
<label className="font-bold text-sm mb-2">Options</label>
<div className="flex flex-col space-y-2">
{options.map((option, index) => (
<div
key={index}
className="
px-4 py-1 w-full rounded-xl
bg-white/50 border-gray-300
border-4 flex flex-row items-center
justify-between"
>
<input
type="text"
value={option}
required
className="
mt-0 border-0 bg-transparent
focus:ring-0 flex-1 text-gray-800"
onChange={(event) =>
updateOption(index, event.target.value)
}
/>
<button
disabled={options.length === 2}
onClick={() => {
removeOption(index);
}}
type="button"
title={
options.length === 2
? "Poll must have at least two options"
: "Remove option"
}
className="
mx-2 bg-slate-800/80
disabled:bg-slate-800/60
rounded-lg p-2 h-fit w-fit"
>
<XCircle className="w-5 h-5" />
</button>
</div>
))}
</div>
<button
onClick={() => setOptions((oldOptions) => [...oldOptions, ""])}
type="button"
title="Add option"
className="
group mt-3 py-2 w-full
rounded-xl bg-white/70
border-white focus:border-blue-400
focus:ring"
>
<PlusSquare
className="
mx-auto text-gray-400
group-hover:text-gray-500"
/>
</button>
</div>
);
};
There are four main features of this component:
Two functions to change the state array, using
array.splice
Text input is rendered for each option to update the option
A button is rendered for each option to remove the option if there are already more than two
A button to add a new option, represented by an empty string
Displaying Votes
Create a file named VotesDisplay.tsx
in the components
folder. Here we use chart.js
to render a bar chart of the results.
import React, { useMemo } from "react";
import { ChartData, ChartOptions } from "chart.js";
import { Bar } from "react-chartjs-2";
import { pollAtom } from "lib/store";
import { useAtomValue } from "jotai";
const chartOptions: ChartOptions<"bar"> = {
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
};
export default function VotesDisplay() {
const { results } = useAtomValue(pollAtom);
const chartData = useMemo<ChartData<"bar">>(() => {
return {
labels: Object.keys(results),
datasets: [
{
data: Object.values(results).map((voters) => voters.length),
borderWidth: 5,
},
],
};
}, [results]);
return <Bar data={chartData} options={chartOptions} />;
}
Choosing Options
Create a file named VotesControl.tsx
in the same folder. Add the following code to render a button for each option. When the user clicks any button, a request to /api/vote
is sent.
import React from "react";
import { useAtomValue } from "jotai";
import { pollAtom } from "lib/store";
import axios from "axios";
export default function VotesControl() {
const { _id: id, results } = useAtomValue(pollAtom);
const onVote = (option: string) => {
return axios.post("/api/vote", {
id,
option,
});
};
return (
<div className="mt-3 p-4 w-full grid gap-3 h-full ">
{Object.keys(results).map((name) => (
<button
key={name}
type="button"
onClick={() => onVote(name)}
className={`h-32 bg-sky-400 rounded-lg px-4 py-3 text-white border-gray-200 text-3xl`}
>
{name}
</button>
))}
</div>
);
}
Presenting the Winner
This final component (EndResult.tsx
) displays a golden award badge to the winning option. However, the poll can still end in a draw.
So, we must have an array of all options with the most voters. Therefore, we can display different messages based on how many winners there are.
Finally, add the following code to the component:
import React from "react";
import { useAtomValue } from "jotai";
import { pollAtom } from "lib/store";
import { Award } from "react-feather";
const listFormatter = new Intl.ListFormat("en");
export default function EndResult() {
const { results } = useAtomValue(pollAtom);
let winningVotes = 0;
let winners: string[] = [];
for (let [candidate, voters] of Object.entries(results)) {
if (voters.length > winningVotes) {
winningVotes = voters.length;
winners = [candidate];
} else if (voters.length === winningVotes) {
winners.push(candidate);
}
}
const isDraw = winners.length > 1;
const bgColor = isDraw ? "bg-slate-400" : "bg-yellow-400";
let message: string;
if (winners.length === Object.keys(results).length) {
message = "Nobody wins. It's a draw!";
} else if (isDraw) {
message = `It's a tie between ${listFormatter.format(winners)}`;
} else {
message = `Winner is ${winners[0]}`;
}
return (
<div
className={`cursor-pointer w-80 rounded-xl px-6 py-4 border-2 border-gray-100 flex flex-col justify-center items-center ${bgColor} text-white`}
>
<Award className="w-10 h-10 mb-2" />
<h2 className="text-2xl font-bold text-center">{message}</h2>
</div>
);
}
Start up the Redis server with sudo redis-server start
, then try and run the project with npm run dev
and see if it works!
Wrapping Up
If you made it this far, I appreciate it. Today we have learnt how to use Next.js, Ably and Mongoose to create a voting application.
Here are some extras to think about:
Mobile responsiveness
Displaying the number of views a poll has
Showing images in polls
Adding a range vote (scale from 1 to 10) as well as single options
To see the final result, you can find the entire code for this article here.
Further reading
Key concepts / Docs | Ably Realtime