React, Vite & WebSockets
In this guide, we will build a simple web application with React for our frontend and Wing for our backend. We will develop and test our application using the Wing Simulator and deploy it to AWS using Terraform.
Our application will have a counter that can be incremented by clicking on it. This counter will be synchronized in real-time across all users via a distributed cloud counter and WebSockets.
🚧 Wing is still under active development, so don't be (too) surprised if you run into issues or bugs along the way. You are invited to join the Wing Discord to say hi, ask questions and help your fellow Wingnuts.
How to use this guide?
This guide is written as a tutorial and intended to be followed step-by-step. At the end of each step, you should be able to find the full source code in a collapsable section.
To expedite the project creation process, consider leveraging the react-vite quickstart template. This quickstart option automates the generation of all the files demonstrated in this tutorial, providing a fast way to set up your project environment:
$ mkdir my-react-vite
$ cd my-react-vite
$ wing new react-vite
Let's check out what we now have in our project directory:
my-react-vite/
├── backend
├ ── frontned
├── package-lock.json
├── package.json
During this tutorial, we'll be focusing on editing the following files:
backend/
├── main.w
├── broadcaster.w
frontend/
├── src/App.tsx
You can also find the entire project in GitHub.
Prerequisites
- Node.js v20 or later.
- IDE support (syntax highlighting, code completions and more):
Step 1 - Installation & Scaffolding
In this step, we will create our project.
Creating a React App with Vite
-
Create our project folder:
mkdir ~/shared-counter
cd ~/shared-counter -
Create a new React app using Vite under the
frontend
directory:npm create -y vite frontend -- --template react-ts
-
Let's ensure your new frontend works:
cd frontend
npm install
npm run devThe result should be a very simple webpage that runs locally and works without a backend. If you open multiple browser tabs you'll see that the counter is not synchronized.
-
Press Ctrl-C to return to the CLI prompt.
Creating a Wing backend
Now, we will create our backend for our app:
-
Install Wing:
npm install -g winglang
wing --version # should be >= 0.60.1 -
Create a
backend
directory under the project root:mkdir ~/shared-counter/backend
cd ~/shared-counter/backend -
Generate a new empty Wing project:
wing new empty
This will generate three files:
package.json
,package-lock.json
andmain.w
file with a simple "hello world" program -
Let's run our new application in the Wing Simulator:
wing it
The Wing Simulator will be opened in your browser and will show a map of your app with a single function.
-
Now, let's invoke our function from the interaction panel and check out the result.
-
Ctrl-C to go back to CLI prompt.
Step 2 - Hello @winglibs/vite
In the previous step, we used npm run dev
to start the local web server.
In this step, we will install the @winglibs/vite
package responsible for starting the dev server.
We will also learn how to send static data from your backend to your frontend application.
Install and use @winglibs/vite
-
Install
@winglibs/vite
:cd ~/shared-counter/backend
npm i @winglibs/vite -
Open up your IDE within the project root:
cd ~/shared-counter
code . -
Clear
backend/main.w
from existing code, and add the following code to bring and instantiate Vite inbackend/main.w
:bring vite;
new vite.Vite(
root: "../frontend"
); -
Open the Wing Simulator again:
cd ~/shared-counter/backend
wing itYou'll notice you both the Wing Simulator and your Vite application opened.
You'll also notice that your Wing application has a Vite resource:
`
Sending data to your Vite app using publicEnv
Now that our backend has a Vite resource, let's explore how to send static data from the backend to the frontend.
-
Edit your
backend/main.w
and add theTITLE
environment variable topublicEnv
:bring vite;
new vite.Vite(
root: "../frontend",
publicEnv: {
TITLE: "Wing + Vite + React"
}
); -
Your web app can now access this environment variable through
window.wing.env
. You can verify this by opening the JavaScript console under Developer Tools and runningconsole.log(window.wing.env);
-
Add this line at the top of
frontend/src/App.tsx
:import "../.winglibs/wing-env.d.ts"
-
Edit
frontend/src/App.tsx
and use replace:<h1>Vite + React</h1>
with:
<h1>{window.wing.env.TITLE}</h1>
-
Upon saving both
main.w
andApp.tsx
, you should see the new title pop up!
Step 3 - Adding a counter
Now that we understand how to send static information from the backend to the frontend, we will create a backend API endpoint and provide the frontend code with its URL. On the frontend, we will switch from using a local counter to a backend-based counter.
Creating a counter and read/update API routes
-
Instantiate a
cloud.Api
inbackend/main.w
by adding the following code:bring vite;
bring cloud;
let api = new cloud.Api(cors: true);
new vite.Vite(
root: "../frontend",
publicEnv: {
TITLE: "Wing + Vite + React",
API_URL: api.url
}
);Notice that we added a new environment variable called
API_URL
to our frontend application which points to the URL of our API endpoint. -
Now, let's create a
cloud.Counter
:let counter = new cloud.Counter();
-
Add the following routes:
GET /counter
will retrieve the counter value usingcounter.peek()
:
api.get("/counter", inflight () => {
return {
body: "{counter.peek()}"
};
});POST /counter
will increment the counter usingcounter.inc()
:
api.post("/counter", inflight () => {
let prev = counter.inc();
return {
body: "{prev + 1}"
};
}); -
Jump over to the Wing Simulator to see that these routes work as expected.
You can click on the API and use the interaction panel to test your endpoints, you can also examine the counter value and even modify it.
main.w
bring vite;
bring cloud;
let api = new cloud.Api(cors: true);
let counter = new cloud.Counter();
api.get("/counter", inflight () => {
return {
body: "{counter.peek()}"
};
});
api.post("/counter", inflight () => {
let prev = counter.inc();
return {
body: "{prev + 1}"
};
});
new vite.Vite(
root: "../frontend",
publicEnv: {
TITLE: "Wing + Vite + React",
API_URL: api.url
}
);
Edit App.tsx
to call our backend
Let's modify our frontend code to fetch and update the counter value using the routes defined above.
-
First, store the
API_URL
in a const at the top offrontend/src/App.tsx
:const API_URL = window.wing.env.API_URL;
-
Then, let's use React hooks to update the counter data:
- Import
useEffect
:
import { useState, useEffect } from 'react';
- Add the code inside the
App
function:
function App() {
const [count, setCount] = useState("NA")
const incrementCount = async () => {
const response = await fetch(`${API_URL}/counter`, {
method: "POST"
});
setCount(await response.text());
}
const updateCount = async () => {
const response = await fetch(`${API_URL}/counter`);
setCount(await response.text());
}
useEffect(() => {
updateCount();
}, []);
// ... - Import
-
Let's trigger the
incrementCount()
function when the user clicks the button:<button key={count} onClick={incrementCount}>
-
Once you save the code, you can examine both the webpage and the Simulator to see how the counter gets incremented.
App.tsx
import { useState, useEffect } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const API_URL = window.wing.env.API_URL;
const [count, setCount] = useState("NA")
const incrementCount = async () => {
const response = await fetch(`${API_URL}/counter`, {
method: "POST"
});
setCount(await response.text());
};
const updateCount = async () => {
const response = await fetch(`${API_URL}/counter`);
setCount(await response.text());
};
useEffect(() => {
updateCount();
}, []);
return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>{window.wing.env.TITLE}</h1>
<div className="card">
<button key={count} onClick={incrementCount}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
);
}
export default App;
Step 4 - Synchronize browsers using @winglibs/websockets
In the current implementation, if we open two browser side-by-side, we only see the counter latest value upon refresh.
In this step we will create a broadcasting service which deploys a WebSocket server on the backend. Clients then connect to this WebSocket to receive real-time notifications when the counter is updated.
When the counter is incremented, the broadcaster service will notify all clients that they need to fetch a new value from our API.
Create a Broadcaster class
The Broadcaster
class contains two public API endpoints:
- a static public WebSocket URL that will be sent to clients through
publicEnv
. - an
inflight
broadcast message, that sends a message to all connected clients
-
First, let's install the
@winglibs/websockets
library:cd ~/shared-counter/backend
npm i @winglibs/websockets -
Create a new file
backend/broadcaster.w
, with the following implementation:bring cloud;
bring websockets;
pub class Broadcaster {
pub url: str;
server: websockets.WebSocket;
clients: cloud.Bucket;
new() {
this.server = new websockets.WebSocket(name: "counter_updates");
this.url = this.server.url;
this.clients = new cloud.Bucket();
// upon connection, add the client to the list
this.server.onConnect(inflight(id: str): void => {
this.clients.put(id, "");
});
// upon disconnect, remove the client from the list
this.server.onDisconnect(inflight(id: str): void => {
this.clients.delete(id);
});
}
// send a message to all clients
pub inflight broadcast(message: str) {
for id in this.clients.list() {
this.server.sendMessage(id, message);
}
}
} -
In
backend/main.w
, lets bring and instantiate our broadcaster service:bring "./broadcaster.w" as b;
let broadcaster = new b.Broadcaster(); -
Send the WebSocket URL to the client:
new vite.Vite(
root: "../frontend",
publicEnv: {
TITLE: "Wing + Vite + React",
WS_URL: broadcaster.url, // <-- add this
API_URL: api.url,
}
); -
Now, every time the counter is increment, let's send a broadcast
"refresh"
message to all our clients. Add this to thePOST /counter
handler:api.post("/counter", inflight () => {
let oldValue = counter.inc();
broadcaster.broadcast("refresh");
return {
body: "{oldValue + 1}"
};
});
main.w
bring vite;
bring cloud;
bring "./broadcaster.w" as b;
let broadcaster = new b.Broadcaster();
let api = new cloud.Api(cors: true);
let counter = new cloud.Counter();
api.get("/counter", inflight () => {
return {
body: "{counter.peek()}"
};
});
api.post("/counter", inflight () => {
let prev = counter.inc();
broadcaster.broadcast("refresh");
return {
body: "{prev + 1}"
};
});
new vite.Vite(
root: "../frontend",
publicEnv: {
TITLE: "Wing + Vite + React",
WS_URL: broadcaster.url,
API_URL: api.url,
}
);
Listen to ws message and trigger data refresh
Let's move to the client.
On the client side we are going to use react-use-websocket
and listen to any event from the
broadcaster, once an event is received we will read the counter value from the API.
-
Start by installing
react-use-websocket
on thefrontend/
:cd ~/shared-counter/frontend
npm i react-use-websocket -
Lets import and use it inside
frontend/App.tsx
:
import useWebSocket from 'react-use-websocket';
-
And use it inside the
App()
function body (after the definition ofupdateCount()
):useWebSocket(window.wing.env.WS_URL, {
onMessage: () => {
updateCount();
}
}); -
Play around by opening multiple tabs of the website; they should automatically update when the counter increments.
App.tsx
import { useState, useEffect } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import useWebSocket from 'react-use-websocket';
function App() {
const API_URL = window.wing.env.API_URL;
const [count, setCount] = useState("NA")
const incrementCount = async () => {
const response = await fetch(`${API_URL}/counter`, {
method: "POST"
});
setCount(await response.text());
};
const updateCount = async () => {
const response = await fetch(`${API_URL}/counter`);
setCount(await response.text());
};
useWebSocket(window.wing.env.WS_URL, {
onMessage: () => {
updateCount();
}
});
useEffect(() => {
updateCount();
}, []);
return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>{window.wing.env.TITLE}</h1>
<div className="card">
<button key={count} onClick={incrementCount}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
);
}
export default App;
Step 5 - Deploy on AWS
Once deployed, the above code translates into the following (simplified) AWS architecture.
Prerequisites
In order to deploy to AWS, you will need:
-
Compile to Terraform/AWS
We will use the
tf-aws
platform to tell the compiler to bind all of our resources to the default set of AWS resources and use Terraform as the provisioning engine.cd ~/shared-counter/backend
wing compile --platform tf-aws main.w -
Run Terraform Init and Apply
cd ./target/main.tfaws
terraform init
terraform apply # this takes some time