Table of Contents

1. Introduction

WebSockets: The Real MVP of Real-Time Web Apps

Today, we’re exploring the world of WebSockets. If you’ve ever wondered how modern web apps achieve that smooth, real-time feel, you’re in for a treat. Let’s break it down!

What’s the Big Deal with WebSockets?

Imagine you’re at a party (a dev party, of course 🎉). In the old days of the web, you’d have to keep asking the host, “Any news?” every few seconds. That’s basically how traditional HTTP requests work - constant asking, or “polling.” Pretty inefficient, right?

Enter WebSockets: It’s like having a direct hotline to the party host. Once you’re connected, you both can shout updates to each other whenever something cool happens. No need to keep asking!

In tech speak, WebSockets enable full-duplex, bidirectional communication between a client (usually a web browser) and a server over a single TCP connection. Unlike the request-response model of HTTP, WebSockets keep the party line open for instant, two-way chatter.

The Cool Kids of WebSocket Town

  1. Always On: Once you establish a WebSocket connection, it stays open. It’s like leaving your WhatsApp open - messages can come through anytime!

  2. Speed Demon: With an open connection, there’s less overhead. This means lower latency compared to constantly knocking on the server’s door with HTTP requests.

  3. Two-Way Street: Both the client and server can initiate a conversation. It’s a true dialogue, not just a Q&A session.

  4. Real-Time Magic: Perfect for apps that need to stay fresh without hitting that refresh button.

Why Should You Care?

  1. Instant Gratification: Great for live chats, gaming, or any app where real-time updates are crucial.

  2. Server’s Best Friend: No more unnecessary polling requests clogging up your server.

  3. UX on Steroids: Users get a snappier, more interactive experience. Happy users, happy life!

  4. Bandwidth Diet: Uses less data compared to constantly asking, “Are we there yet?”

  5. Scaling Up: Handles tons of connections more efficiently than traditional HTTP.

  6. Plays Well with Others: Facilitates real-time chat between different domains. Useful for when your app needs to make friends across the internet.

Where You’ve Seen WebSockets in Action

  • That addictive live chat on your favorite shopping site
  • Google Docs’ collaborative magic
  • Live sports scores that update faster than you can say “goal!”
  • Multiplayer games that don’t lag (well, mostly)
  • Your smart home devices staying in sync
  • Those fancy real-time dashboards that make you look good in meetings

WebSockets in Our Live Polling App

For our live polling system, WebSockets are the secret sauce. They’ll zap vote counts and poll results to all connected clients faster than you can click “submit.” No more furiously refreshing the page or pestering the server with constant requests. It’s smooth, it’s real-time!

Stay tuned as we dive deeper into building this WebSocket-powered polling app with Remix.js and Rails.

2. Setting Up the Project

Rails API Backend Setup

Okey, let’s get started. Let’s start by creating a new Rails API project.

1
rails new live_polling_api --api --database=postgresql

Here, we’re using the –api flag to set up a lightweight API-only application and –database=postgresql to set up a postgresql database.

Edit config/database.yml to set up your PostgreSQL database credentials.

Let’s configure CORS. Add the following to config/initializers/cors.rb:

1
2
3
4
5
6
7
8
9
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:3000' # Adjust this for your Remix.js app URL
    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: true
  end
end

Now, we need to configure Action Cable. Edit config/application.rb to include the following:

1
2
3
4
5
6
module LivePollingApi
  class Application < Rails::Application
    # ...
    config.action_cable.mount_path = '/cable'
  end
end

Next, we’ll need to create a new database and migrate the database.

1
2
rails db:create
rails db:migrate

Next, let’s add some additional gems to our project:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Gemfile
gem 'rack-cors'
gem 'redis'
gem 'sidekiq'
gem 'dotenv-rails'
gem 'jbuilder'

group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'faker'
end

Setting up a new Remix.js project

1
npx create-remix@latest live_polling_remix_frontend

I’ll keep this simple and just add a few extra folders and files to the project.

To the apps folder, add a components folder and a lib folder. Under lib, create a types.ts and api.ts

  • Installing necessary dependencies and creating project structure

3. Designing the Data Model (Rails)

Designing the Data Model for Our Live Polling System

In this section, we’ll set up the core models for our live polling application: Poll, Option, and Vote. These models will form the backbone of our data structure, allowing us to create polls, add options, and record votes efficiently.

Creating the Poll Model

Let’s start by generating our Poll model. This will be the primary model in our application:

1
rails generate model Poll title:string description:text end_date:datetime

This command creates a Poll model with a title, description, and end date. The title and description will provide information about the poll, while the end date allows us to set a time limit for voting.

Creating the Option Model

Next, we’ll create the Option model. Each poll needs a set of options for users to choose from:

1
rails generate model Option content:string poll:references

This generates an Option model with content (the text of the option) and a reference to the Poll it belongs to.

Creating the Vote Model

Finally, we need a way to record votes:

1
rails generate model Vote option:references

This creates a Vote model that references an Option, allowing us to track which option was chosen for each vote.

Understanding Model Relationships

Now that we have our models, let’s explore how they relate to each other. Rails makes it easy to establish these relationships:

  1. Poll and Option Relationship

    A Poll can have multiple Options, creating a one-to-many relationship:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    # app/models/poll.rb
    class Poll < ApplicationRecord
      has_many :options, dependent: :destroy
      accepts_nested_attributes_for :options, allow_destroy: true
    end
    
    # app/models/option.rb
    class Option < ApplicationRecord
      belongs_to :poll
    end
    

    The dependent: :destroy ensures that when a poll is deleted, its associated options are also removed, maintaining data integrity.

  2. Option and Vote Relationship

    Similarly, an Option can have multiple Votes:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    # app/models/option.rb
    class Option < ApplicationRecord
      belongs_to :poll
      has_many :votes
    end
    
    # app/models/vote.rb
    class Vote < ApplicationRecord
      belongs_to :option
    end
    
  3. Poll and Vote Relationship

    While there’s no direct relationship between Polls and Votes, we can establish an indirect connection:

    1
    2
    3
    4
    5
    6
    
    # app/models/poll.rb
    class Poll < ApplicationRecord
      has_many :options, dependent: :destroy
      accepts_nested_attributes_for :options, allow_destroy: true
      has_many :votes, through: :options
    end
    

    This allows us to easily access all votes for a given poll, even though votes are directly associated with options.

The Big Picture

With this structure in place, we’ve created a flexible and powerful data model:

  • Polls serve as the main container, holding information about the voting event.
  • Options provide the choices within each poll.
  • Votes record the selections made by users.

This design allows us to:

  • Create new polls with multiple options
  • Record votes for specific options
  • Easily tally votes for individual options or entire polls
  • Maintain a clear, organized data structure

Remember to run rails db:migrate after creating these models to update your database schema. With our data model in place, we’re now ready to build the API endpoints that will bring our live polling system to life.

4. Building the API Endpoints (Rails)

Building API Endpoints for Our Live Polling System

In this section, we’ll create the necessary API endpoints for our live polling application. We’ll focus on polls and votes, implement CRUD operations, and ensure our API is accessible from our Remix.js frontend.

Creating API Controllers

First, let’s generate our API controllers. We’ll use Rails’ namespacing to organize our API under v1:

1
2
rails generate controller api/v1/polls
rails generate controller api/v1/votes

These commands create controller files in app/controllers/api/v1/.

Implementing CRUD Operations for Polls

Let’s implement CRUD (Create, Read, Update, Delete) operations for polls in app/controllers/api/v1/polls_controller.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Api::V1::PollsController < ApplicationController
  def index
    polls = Poll.all
    render json: polls, include: :options
  end

  def show
    poll = Poll.find(params[:id])
    render json: poll, include: { options: { include: :votes } }
  end

  def create
    poll = Poll.new(poll_params)
    if poll.save
      render json: poll, status: :created
    else
      render json: poll.errors, status: :unprocessable_entity
    end
  end

  def update
    poll = Poll.find(params[:id])
    if poll.update(poll_params)
      render json: poll
    else
      render json: poll.errors, status: :unprocessable_entity
    end
  end

  def destroy
    poll = Poll.find(params[:id])
    poll.destroy
    head :no_content
  end

  private

  def poll_params
    params.require(:poll).permit(:title, :description, :end_date, options_attributes: [:id, :content, :_destroy])
  end
end

Adding Endpoints for Casting Votes

Now, let’s implement the voting functionality in app/controllers/api/v1/votes_controller.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Api::V1::VotesController < ApplicationController
  def create
    vote = Vote.new(vote_params)
    if vote.save
      render json: vote, status: :created
    else
      render json: vote.errors, status: :unprocessable_entity
    end
  end

  private

  def vote_params
    params.require(:vote).permit(:option_id)
  end
end

Setting Up Routes

Let’s define our API routes in config/routes.rb:

1
2
3
4
5
6
7
8
9
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :polls do
        resources :votes, only: [:create]
      end
    end
  end
end

This sets up nested routes for votes under polls, allowing for URLs like /api/v1/polls/1/votes.

Setting up CORS for Frontend Access

To allow our Remix.js frontend to access these API endpoints, we need to configure CORS (Cross-Origin Resource Sharing). We’ve already added the rack-cors gem to our Gemfile, so let’s configure it.

In config/initializers/cors.rb, add:

1
2
3
4
5
6
7
8
9
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:3000' # Adjust this to your Remix.js app's URL
    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: true
  end
end

Make sure to replace 'http://localhost:3000' with the actual URL of your Remix.js application.

Testing Our API

With these endpoints in place, you can now:

  1. Create a new poll: POST to /api/v1/polls
  2. List all polls: GET to /api/v1/polls
  3. View a specific poll: GET to /api/v1/polls/:id
  4. Update a poll: PUT to /api/v1/polls/:id
  5. Delete a poll: DELETE to /api/v1/polls/:id
  6. Cast a vote: POST to /api/v1/polls/:poll_id/votes

We can use tools like cURL and Postman while we build out the frontend.

Remember to restart your Rails server after making these changes. With these API endpoints set up, we’ve laid the groundwork for our live polling system’s backend. Next, we’ll focus on integrating these endpoints with our Remix.js frontend and implementing real-time updates with Action Cable.

5. Setting Up WebSockets (Rails)

Setting Up WebSockets with Action Cable for Real-Time Updates

In this section, we’ll configure Action Cable to enable real-time updates in our live polling system. We’ll create a channel for live updates and explain how the publish-subscribe model works in our application context.

Configuring Action Cable in Rails

Action Cable seamlessly integrates WebSockets with the rest of your Rails application. Let’s start by configuring it:

  1. First, ensure Redis is installed and running on your system, as Action Cable uses Redis as its default adapter.

  2. In config/application.rb, add the following line to allow Action Cable to accept WebSocket requests:

    1
    
    config.action_cable.mount_path = '/cable'
    
  3. In config/environments/development.rb, add:

    1
    
    config.action_cable.allowed_request_origins = ['http://localhost:3000']
    

    Replace http://localhost:3000 with your Remix.js app’s URL.

    Alternatively, you can disable the origin check in development by adding:

    1
    
    config.action_cable.disable_request_forgery_protection = true
    

    The second option is useful during development since we can use a simple chrome extension to test the websocket connection.

Creating a Channel for Live Updates

Now, let’s create a channel for our live updates:

  1. Generate a new channel:

    1
    
    rails generate channel Poll
    
  2. Open app/channels/poll_channel.rb and modify it:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    class PollChannel < ApplicationCable::Channel
      def subscribed
        stream_from "poll_channel"
      end
    
      def unsubscribed
        # Any cleanup needed when channel is unsubscribed
      end
    
      def receive(data)
        ActionCable.server.broadcast("poll_channel", data)
      end
    end
    

Implementing Real-Time Updates

To broadcast updates when a vote is cast, modify the votes_controller.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Api::V1::VotesController < ApplicationController
  def create
    vote = Vote.new(vote_params)
    if vote.save
      poll = vote.option.poll
      ActionCable.server.broadcast(
        "poll_channel",
        {
          poll_id: poll.id,
          option_id: vote.option_id,
          votes_count: vote.option.votes.count
        }
      )
      render json: vote, status: :created
    else
      render json: vote.errors, status: :unprocessable_entity
    end
  end

  private

  def vote_params
    params.require(:vote).permit(:option_id)
  end
end

Understanding the Publish-Subscribe Model in Our App

The publish-subscribe (pub/sub) model is at the heart of how we’re using WebSockets in our live polling system. Here’s how it works in our context:

  1. Subscribe: When a user opens a poll, their client (the Remix.js app) subscribes to the poll_channel. This is like tuning into a radio station.

  2. Publish: When any user casts a vote, our Rails app publishes (broadcasts) this update to the poll_channel. This is like the radio station sending out a signal.

  3. Receive: All subscribed clients receive this update in real-time. They can then update their local state to reflect the new vote count without refreshing the page.

This model allows for efficient, real-time communication. Instead of clients constantly polling the server for updates, the server proactively sends out updates when they occur. This reduces server load and provides a more responsive user experience.

Testing WebSocket Connection

To test your WebSocket connection before integrating with the frontend, we can use a Chrome extension.

  1. Start your Rails server:

    1
    
    rails server
    
  2. Open the chrome extension and connect to ws://localhost:3000/cable.

If you see “WebSocket connection established”, your Action Cable setup is working correctly.

In the next section, we’ll integrate this WebSocket functionality with our Remix.js frontend to create a truly real-time live polling experience.

6. Remix.js Frontend Setup (Remix.js)

Setting Up the Remix.js Frontend for Our Live Polling System

In this section, we’ll set up the frontend of our live polling system using Remix.js. We’ll explore the Remix.js project structure, set up routing, and create base layouts and components.

Creating a New Remix.js Project

After creating the project, you’ll see a structure like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
app/
├── entry.client.tsx
├── entry.server.tsx
├── root.tsx
├── routes/
│   └── index.tsx
public/
├── favicon.ico
remixjs.d.ts
package.json
remix.config.js
tsconfig.json

Key directories and files:

  • app/: Contains the core of your Remix application.
  • app/routes/: Where you define your routes and page components.
  • app/root.tsx: The root component of your application.
  • public/: For static assets.

Setting Up Routing in Remix

Remix uses a file-based routing system. Let’s set up some routes for our polling app:

  1. Create app/routes/polls._index.tsx for listing polls:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { json, useLoaderData } from "@remix-run/react";
import type { LoaderFunction } from "@remix-run/node";
import PollCard from "~/components/PollCard";
import type { Poll } from "~/lib/types";

export const loader: LoaderFunction = async () => {
	const response = await fetch("http://localhost:3000/api/v1/polls");
	return json(await response.json());
};

export default function Polls() {
	const polls = useLoaderData<typeof loader>();

	return (
		<div>
			<h1 className="text-3xl font-bold text-purple-800 mb-6">Polls</h1>
			<ul className="space-y-4">
				{polls.map((poll: Poll) => (
					<li key={poll.id}>
						<PollCard poll={poll} />
					</li>
				))}
			</ul>
		</div>
	);
}

1.1 Let’s also update the types to include the new interfaces:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export interface Poll {
    id: number;
    title: string;
    description: string;
    options: PollOption[];
}

export interface PollOption {
    id: number;
    content: string;
}
  1. Create app/routes/polls.$pollId.tsx for individual polls:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { json, useLoaderData, useParams } from "@remix-run/react";
import type { LoaderFunction } from "@remix-run/node";
import type { PollOption } from "~/lib/types";

export const loader: LoaderFunction = async ({ params }) => {
	const response = await fetch(
		`http://localhost:3000/api/v1/polls/${params.pollId}`,
	);
	return json(await response.json());
};

export default function Poll() {
	const poll = useLoaderData<typeof loader>();

	return (
		<div className="bg-yellow-50 rounded-lg shadow-md p-6">
			<h1 className="text-3xl font-bold text-yellow-800 mb-4">{poll.title}</h1>
			<p className="text-yellow-700 mb-6">{poll.description}</p>
			<ul className="space-y-2">
				{poll.options.map((option: PollOption) => (
					<li
						key={option.id}
						className="bg-yellow-100 p-3 rounded-md text-yellow-800 hover:bg-yellow-200 transition duration-300"
					>
						{option.content}
					</li>
				))}
			</ul>
		</div>
	);
}

Creating Base Layouts and Components

Let’s create a base layout and some reusable components:

  1. Create app/components/Layout.tsx:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Link } from "@remix-run/react";

export default function Layout({ children }: { children: React.ReactNode }) {
	const currentYear = new Date().getFullYear();

	return (
		<div className="min-h-screen flex flex-col bg-pink-50">
			<header className="bg-purple-200 p-4">
				<nav className="container mx-auto flex justify-between items-center">
					<Link to="/" className="text-2xl font-bold text-purple-800">
						Live Polling
					</Link>
					<div>
						<Link to="/" className="text-purple-800 hover:text-purple-600 mr-4">
							Home
						</Link>
						<Link to="/polls" className="text-purple-800 hover:text-purple-600">
							Polls
						</Link>
					</div>
				</nav>
			</header>
			<main className="container mx-auto flex-grow p-4">{children}</main>
			<footer className="bg-purple-200 p-4 text-center text-purple-800">
				© {currentYear} Live Polling App
			</footer>
		</div>
	);
}
  1. Create app/components/PollCard.tsx:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { Link } from "@remix-run/react";
import type { Poll } from "~/lib/types";

export default function PollCard({ poll }: { poll: Poll }) {
	return (
		<div className="bg-blue-100 rounded-lg shadow-md p-6 mb-4">
			<h2 className="text-xl font-semibold text-blue-800 mb-2">{poll.title}</h2>
			<p className="text-blue-600 mb-4">{poll.description}</p>
			<Link
				to={`/polls/${poll.id}`}
				className="bg-green-300 text-green-800 px-4 py-2 rounded hover:bg-green-400 transition duration-300"
			>
				Vote Now
			</Link>
		</div>
	);
}
  1. Update app/root.tsx to use the Layout:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "remix";
import Layout from "./components/Layout";

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <Layout>
          <Outlet />
        </Layout>
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

Updating the Index Route

Finally, let’s update app/routes/_index.tsx to welcome users to our polling app:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import type { MetaFunction } from "@remix-run/node";

export const meta: MetaFunction = () => {
	return [
		{ title: "Live Polling App" },
		{
			name: "description",
			content: "Create and participate in real-time polls!",
		},
	];
};

export default function Index() {
	return (
		<div className="text-center">
			<h1 className="text-4xl font-bold text-purple-800 mb-4">
				Welcome to Live Polling
			</h1>
			<p className="text-xl text-purple-600">
				Create and participate in real-time polls!
			</p>
		</div>
	);
}

With this setup, we’ve created a basic structure for our Remix.js frontend. We have routes for listing polls and viewing individual polls, a layout component for consistent page structure, and a reusable poll card component.

Go ahead and run the dev server with:

1
npm run dev

In the next sections, we’ll integrate this frontend with our Rails API and implement real-time updates using WebSockets.

7. Implementing API Integration in Remix

In this section, we’ll connect our Remix.js frontend to our Rails API. We’ll cover setting up API calls, handling form submissions, and implementing error handling and validation.

Setting up API Calls Using Remix’s Data Loading Patterns

Remix provides powerful data loading patterns through its loader and action functions. Let’s implement these for our polls:

  1. Update app/routes/polls._index.tsx to fetch all available polls:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { json, useLoaderData, Link } from "@remix-run/react";
import type { LoaderFunction } from "@remix-run/node";
import PollCard from "~/components/PollCard";
import type { Poll } from "~/lib/types";

export const loader: LoaderFunction = async () => {
	try {
		const response = await fetch("http://localhost:3000/api/v1/polls");
		if (!response.ok) {
			throw new Error("Failed to fetch polls");
		}
		const polls = await response.json();
		return json(polls);
	} catch (error) {
		console.error("Error loading polls:", error);
		return json({ error: "Failed to load polls" }, { status: 500 });
	}
};

export default function Polls() {
	const data = useLoaderData<typeof loader>();

	if ("error" in data) {
		return <div className="text-red-600 text-center">{data.error}</div>;
	}

	return (
		<div>
			<h1 className="text-3xl font-bold text-purple-800 mb-6">Polls</h1>
			<ul className="space-y-4">
				{data.map((poll: Poll) => (
					<li key={poll.id}>
						<PollCard poll={poll} />
					</li>
				))}
			</ul>
			<Link
				to="/polls/new"
				className="mt-6 inline-block bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 transition duration-300"
			>
				Create New Poll
			</Link>
		</div>
	);
}
  1. Update app/routes/polls.$pollId.tsx to fetch a single poll and handle voting:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import {
	Form,
	json,
	useLoaderData,
	useActionData,
	redirect,
} from "@remix-run/react";
import type { LoaderFunction, ActionFunction } from "@remix-run/node";
import type { PollOption } from "~/lib/types";

export const loader: LoaderFunction = async ({ params }) => {
	try {
		const response = await fetch(
			`http://localhost:3000/api/v1/polls/${params.pollId}`,
		);
		if (!response.ok) {
			throw new Error("Failed to fetch poll");
		}
		const poll = await response.json();
		return json(poll);
	} catch (error) {
		console.error("Error loading poll:", error);
		return json({ error: "Failed to load poll" }, { status: 500 });
	}
};

export const action: ActionFunction = async ({ request, params }) => {
	const formData = await request.formData();
	const optionId = formData.get("optionId");

	if (!optionId) {
		return json({ error: "Please select an option" }, { status: 400 });
	}

	try {
		const response = await fetch(
			`http://localhost:3000/api/v1/polls/${params.pollId}/votes`,
			{
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ vote: { option_id: optionId } }),
			},
		);

		if (!response.ok) {
			throw new Error("Failed to cast vote");
		}

		return redirect(`/polls/${params.pollId}`);
	} catch (error) {
		console.error("Error casting vote:", error);
		return json({ error: "Failed to cast vote" }, { status: 500 });
	}
};

export default function Poll() {
	const data = useLoaderData<typeof loader>();
	const actionData = useActionData<typeof action>();

	if ("error" in data) {
		return <div className="text-red-600 text-center">{data.error}</div>;
	}

	return (
		<div className="bg-yellow-50 rounded-lg shadow-md p-6">
			<h1 className="text-3xl font-bold text-yellow-800 mb-4">{data.title}</h1>
			<p className="text-yellow-700 mb-6">{data.description}</p>
			<Form method="post" className="space-y-4">
				{actionData?.error && (
					<p className="text-red-600">{actionData.error}</p>
				)}
				{data.options.map((option: PollOption) => (
					<div key={option.id} className="flex items-center">
						<input
							type="radio"
							id={option.id.toString()}
							name="optionId"
							value={option.id}
							className="mr-2"
						/>
						<label htmlFor={option.id.toString()} className="text-yellow-800">
							{option.content} - Votes: {option.votes?.length || 0}
						</label>
					</div>
				))}
				<button
					type="submit"
					className="bg-green-300 text-green-800 px-4 py-2 rounded hover:bg-green-400 transition duration-300"
				>
					Vote
				</button>
			</Form>
		</div>
	);
}

Handling Form Submissions for Creating Polls and Casting Votes

Now, let’s implement form submissions for creating polls and casting votes:

  1. Create app/routes/polls.new.tsx for creating new polls:
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
import { Form, redirect, useActionData } from "@remix-run/react";
import type { ActionFunction } from "@remix-run/node";

export const action: ActionFunction = async ({ request }) => {
	const formData = await request.formData();
	const title = formData.get("title");
	const description = formData.get("description");
	const options = formData.getAll("options");

	if (!title || !description || options.length < 2) {
		return { error: "Please fill all required fields" };
	}

	try {
		const response = await fetch("http://localhost:3000/api/v1/polls", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				poll: {
					title,
					description,
					options_attributes: options.map((o) => ({ content: o })),
				},
			}),
		});

		if (!response.ok) {
			throw new Error("Failed to create poll");
		}

		return redirect("/polls");
	} catch (error) {
		console.error("Error creating poll:", error);
		return { error: "Failed to create poll" };
	}
};

export default function NewPoll() {
	const actionData = useActionData<typeof action>();

	return (
		<div className="bg-blue-50 rounded-lg shadow-md p-6">
			<h1 className="text-3xl font-bold text-blue-800 mb-6">Create New Poll</h1>
			<Form method="post" className="space-y-4">
				{actionData?.error && (
					<p className="text-red-600">{actionData.error}</p>
				)}
				<div>
					<label htmlFor="title" className="block text-blue-700 mb-2">
						Title:
					</label>
					<input
						type="text"
						id="title"
						name="title"
						required
						className="w-full p-2 border border-blue-300 rounded"
					/>
				</div>
				<div>
					<label htmlFor="description" className="block text-blue-700 mb-2">
						Description:
					</label>
					<textarea
						id="description"
						name="description"
						required
						className="w-full p-2 border border-blue-300 rounded"
					/>
				</div>
				<div>
					<label htmlFor="option1" className="block text-blue-700 mb-2">
						Option 1:
					</label>
					<input
						type="text"
						id="option1"
						name="options"
						required
						className="w-full p-2 border border-blue-300 rounded"
					/>
				</div>
				<div>
					<label htmlFor="option2" className="block text-blue-700 mb-2">
						Option 2:
					</label>
					<input
						type="text"
						id="option2"
						name="options"
						required
						className="w-full p-2 border border-blue-300 rounded"
					/>
				</div>
				<button
					type="submit"
					className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition duration-300"
				>
					Create Poll
				</button>
			</Form>
		</div>
	);
}

Error Handling and Validation

As you can see in the code above, we’ve implemented error handling and validation in several ways:

  1. In the loader functions, we catch any errors that occur during the API calls and return an error message with an appropriate status code.

  2. In the action functions for creating polls and casting votes, we validate the form data before sending it to the API. If the data is invalid, we return an error message.

  3. In the components, we display any error messages returned by the loader or action functions.

  4. We use try-catch blocks to handle any unexpected errors that might occur during API calls.

This approach ensures that we handle errors gracefully and provide feedback to the user when something goes wrong.

By implementing these patterns, we’ve created a robust integration between our Remix.js frontend and our Rails API. Users can now view polls, create new polls, and cast votes, with all actions properly validated and error-handled.

8. Real-time Updates with WebSockets in Remix

Implementing Real-time Updates with WebSockets in Remix

In this section, we’ll add real-time functionality to our Remix.js frontend using WebSockets. We’ll connect to the WebSocket server, implement client-side logic for real-time updates, and use Remix’s useFetcher for optimistic UI updates.

Connecting to WebSocket from Remix

First, let’s create a utility function to manage our WebSocket connection. Create a new file app/utils/websocket.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let socket: WebSocket | null = null;

export function getSocket(): WebSocket {
    if (!socket) {
        socket = new WebSocket('ws://localhost:3000/cable');

        socket.onopen = () => {
            console.log('WebSocket connected');
            if (socket) {
                socket.send(JSON.stringify({
                    command: 'subscribe',
                    identifier: JSON.stringify({ channel: 'PollChannel' }),
                }));
            };
        }

        socket.onerror = (error) => {
            console.error('WebSocket error:', error);
        };

        socket.onclose = () => {
            console.log('WebSocket disconnected');
            socket = null;
        };
    }

    return socket;
}

This function creates a singleton WebSocket connection and handles the initial subscription to our PollChannel.

Implementing Client-side Logic for Real-time Updates

Now, let’s update our poll detail page to use this WebSocket connection. Update app/routes/polls.$pollId.tsx:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { useEffect, useState } from "react";
import {
	Form,
	json,
	useLoaderData,
	useActionData,
	redirect,
	useFetcher,
} from "@remix-run/react";
import type { LoaderFunction, ActionFunction } from "@remix-run/node";
import type { PollData, PollOption } from "~/lib/types";
import { getSocket } from "~/utils/websocket";

export const loader: LoaderFunction = async ({ params }) => {
	try {
		const response = await fetch(
			`http://localhost:3000/api/v1/polls/${params.pollId}`,
		);
		if (!response.ok) {
			throw new Error("Failed to fetch poll");
		}
		const poll = await response.json();
		return json(poll);
	} catch (error) {
		console.error("Error loading poll:", error);
		return json({ error: "Failed to load poll" }, { status: 500 });
	}
};

export const action: ActionFunction = async ({ request, params }) => {
	const formData = await request.formData();
	const optionId = formData.get("optionId");

	if (!optionId) {
		return json({ error: "Please select an option" }, { status: 400 });
	}

	try {
		const response = await fetch(
			`http://localhost:3000/api/v1/polls/${params.pollId}/votes`,
			{
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ vote: { option_id: optionId } }),
			},
		);

		if (!response.ok) {
			throw new Error("Failed to cast vote");
		}

		return redirect(`/polls/${params.pollId}`);
	} catch (error) {
		console.error("Error casting vote:", error);
		return json({ error: "Failed to cast vote" }, { status: 500 });
	}
};

export default function Poll() {
	const initialData = useLoaderData<typeof loader>();
	const actionData = useActionData<typeof action>();
	const fetcher = useFetcher();
	const [pollData, setPollData] = useState(initialData);
	const [votedOptionId, setVotedOptionId] = useState<number | null>(null);

	useEffect(() => {
		const socket = getSocket();

		socket.onmessage = (event) => {
			const data = JSON.parse(event.data);
			if (data.message && data.message.poll_id === pollData.id) {
				setPollData((prevData: PollData) => ({
					...prevData,
					options: prevData.options.map((option: PollOption) =>
						option.id === data.message.option_id
							? { ...option, votes: new Array(data.message.votes_count) }
							: option,
					),
				}));
			}
		};

		return () => {
			if (socket.readyState === WebSocket.OPEN) {
				socket.send(
					JSON.stringify({
						command: "unsubscribe",
						identifier: JSON.stringify({ channel: "PollChannel" }),
					}),
				);
			}
		};
	}, [pollData.id]);

	const handleVote = (optionId: number) => {
		setVotedOptionId(optionId);
		fetcher.submit(
			{ optionId: optionId.toString() },
			{ method: "post", action: `/polls/${pollData.id}` },
		);
	};

	if ("error" in pollData) {
		return <div className="text-red-600 text-center">{pollData.error}</div>;
	}

	const isVoted = votedOptionId !== null || fetcher.state === "submitting";

	return (
		<div className="bg-yellow-50 rounded-lg shadow-md p-6">
			<h1 className="text-3xl font-bold text-yellow-800 mb-4">
				{pollData.title}
			</h1>
			<p className="text-yellow-700 mb-6">{pollData.description}</p>
			{actionData?.error && (
				<p className="text-red-600 mb-4">{actionData.error}</p>
			)}
			<div className="space-y-4">
				{pollData.options.map((option: PollOption) => (
					<div key={option.id} className="flex items-center justify-between">
						<button
							onClick={() => handleVote(option.id)}
							disabled={isVoted}
							className={`flex-grow text-left px-4 py-2 rounded transition duration-300 ${
								isVoted
									? "bg-gray-100 text-gray-500 cursor-not-allowed"
									: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200"
							} ${
								votedOptionId === option.id ? "border-2 border-green-500" : ""
							}`}
							type="button"
						>
							<span className="flex items-center">
								{option.content}
								{votedOptionId === option.id && (
									<svg
										className="ml-2 h-5 w-5 text-green-500"
										fill="none"
										strokeLinecap="round"
										strokeLinejoin="round"
										strokeWidth="2"
										viewBox="0 0 24 24"
										stroke="currentColor"
										aria-label="Voted"
									>
										<title>Voted</title>
										<path d="M5 13l4 4L19 7" />
									</svg>
								)}
							</span>
						</button>
						<span className="ml-4 text-yellow-800">
							Votes: {option.votes?.length || 0}
						</span>
					</div>
				))}
			</div>
		</div>
	);
}

Let’s break down the changes:

  1. We import the getSocket function and necessary hooks from React and Remix.
  2. We use useState to manage the poll data, initializing it with the data from the loader.
  3. In the useEffect hook, we set up the WebSocket connection and handle incoming messages.
  4. We update the poll data in real-time when we receive a message from the WebSocket.
  5. We clean up the WebSocket subscription when the component unmounts.

Using Remix’s useFetcher for Optimistic UI Updates

We’re using Remix’s useFetcher hook to handle voting. This allows us to update the UI optimistically, providing a more responsive user experience.

In the handleVote function:

1
2
3
4
5
6
7
const handleVote = (optionId: number) => {
	setVotedOptionId(optionId);
	fetcher.submit(
		{ optionId: optionId.toString() },
		{ method: "post", action: `/polls/${pollData.id}` },
	);
};

We use fetcher.submit to send the vote to the server. While the request is in progress, we can update the UI immediately, giving the user instant feedback.

In the render method, we disable the vote buttons and the selected option marked as voted. This prevents users from submitting multiple votes while one is still being processed (in a real-world scenario, we would more sofisticated validations for preventing double votes).

With these changes, our polling app now updates in real-time:

  • When a user votes, the UI updates immediately (optimistic update).
  • If other users vote, all connected clients see the updated vote counts in real-time.
  • The WebSocket connection ensures that updates are pushed to the client, reducing the need for polling and improving the app’s responsiveness. This is what it looks like:

Remember to test your webSocket connection thoroughly and handle potential disconnections gracefully in a production environment.

9. Conclusion: Reflecting on Our Live Polling System

As we wrap up this rather long tutorial, let’s take a moment to recap what we’ve built, and compare our approach to traditional methods.

Recap of What We’ve Built

Throughout this tutorial, we’ve created a real-time live polling system using a modern tech stack:

  1. Backend: We built a Ruby on Rails API that handles:

    • CRUD operations for polls and votes
    • WebSocket connections via Action Cable for real-time updates
  2. Frontend: We developed a Remix.js application that provides:

    • A responsive user interface for creating polls and casting votes
    • Real-time updates using WebSocket connections
    • Optimistic UI updates for a smoother user experience
  3. Key Features:

    • Creating and managing polls
    • Real-time voting and result updates
    • Seamless integration between frontend and backend
    • Efficient data loading and form handling with Remix

This project demonstrates how to combine the strengths of Rails for backend operations with the modern frontend capabilities of Remix.js, all tied together with real-time WebSocket communication.

Comparing This Approach to a Traditional Rails App

Our approach differs from a traditional Rails application in several key ways:

  1. Separation of Concerns: By using Rails as an API and Remix for the frontend, we’ve created a clear separation between backend and frontend logic. This can lead to easier maintenance and the ability to scale each part independently.

  2. Enhanced Frontend Capabilities: Remix.js provides powerful features like nested routing, server-side rendering, and efficient data loading patterns. These can lead to a more responsive and performant user experience compared to traditional server-rendered Rails views.

  3. Real-Time Functionality: While it’s possible to implement WebSockets in a monolithic Rails app, our approach with a dedicated frontend makes it easier to manage real-time state and updates.

  4. API-First Design: This approach encourages thinking in terms of API design from the start, which can be beneficial if you ever need to support multiple frontends or third-party integrations.

  5. Modern JavaScript Ecosystem: Using Remix allows us to tap into the vast React and JavaScript ecosystem for additional libraries and tools.

However, this approach also comes with trade-offs:

  • Increased complexity in project setup and deployment
  • Potential for duplication of logic between frontend and backend
  • Need for developers to be proficient in both Rails and modern JavaScript frameworks

Additional Resources for Learning

To deepen your understanding of the technologies we’ve used, consider exploring these resources:

  1. Remix.js:

  2. Ruby on Rails API:

  3. WebSockets and Action Cable:

  4. Full-Stack JavaScript:

  5. API Design:

Repositories

Rails API

Remix Frontend