Previously, we implemented the gRPC backend service for a simple trading app.
If you got this far, you should have a backend with working endpoints for
managing stocks, sending orders and retrieving positions.
In this post, we’ll develop the frontend with Remix and TypeScript,
using gRPC to communicate with our backend.
For a more attractive UI, we’ll use Tailwind
with shadcn UI components.
All work in this post will occur within the webapp directory.
Introduction to Remix
Remix is a full stack React framework. It provides a comprehensive solution for client-side rendering,
server-side rendering, routing and data fetching. It is an opinionated framework covering
the View and Controller aspects of a traditional MVC framework, but remains unopinionated about
the Model.
While it is possible to build entire full stack applications with Remix,
and talk to a database directly from the server-side code, it may be desirable
to use Remix in conjuction with a traditional backend stack in more
complex cases.
We will do just that - instead of talking to a database, we’ll proxy requests through
to the gRPC service we have written in Rust.
We’ll use nice-grpc,
a Node.js gRPC library, to automatically generate TypeScript types for our API layer.
Moreover, Remix lets us directly share these types with the frontend, thanks to its full stack features.
A single post can’t cover Remix in detail, but the official tutorial is excellent.
If you are unfamiliar with Remix, it’s highly recommended to check it out
before proceeding.
The starting repository comes with a set of dependencies for the UI
and development. Before proceeding, install the dependencies.
Generating the gRPC client
Let’s start by adding nice-grpc as a dependency.
In addition to nice-grpc, it installs the necessary tooling to compile
proto files.
Even though we won’t change the gRPC service definitions further,
in a real-world scenario, you’d often regenerate the client library to keep up with API changes.
To help with code generation, we’ll create a new file codegen.sh.
This script takes the definitions from the API and generates a client
library into ./app/api/generated.
Showing the positions
If you previously used grpcui to interact with the trade service, you may
already have a few positions. Let’s display these in the app!
Fetching the positions
We’ll start by creating a simple wrapper for the API layer to streamline
the instantiation of the client with the correct API URL.
We use the .server.ts suffix to tell Remix that this code should
only ever be used on the server. We now have an allPositions function
we can use to fetch the positions.
Before this can be used, we need to set the API URL in a .env file.
Displaying the positions
The positions will be displayed in a table in the application. Let’s start
by adding the table component using shadcn.
Next, we’ll add a new loader that will load the positions using the function
we created for fetching positions. In the Index component, we”ll iterate
over the positions and display them in the table.
Now that we can see positions in the UI, let’s make it interactive by adding a form for new orders.
The form component
Our form will require 3 UI components from shadcn - a button to submit the form,
an input field for the quantity, and a select widget for the stock.
Let’s install these:
In order to validate our form both in the browser and on the server, we’ll use
remix-validated-form with zod. These should already be installed.
Note, types like Stock and Side come from the auto-generated code, which
in turn is defined by the Rust service.
Adding the form to the page
The form component expects a list of stocks to populate the stock select
component. We can get the list of stocks the same way we got the list of
positions.
Let’s define an API wrapper for reference data, refdata.server.ts:
and extend the loader function to fetch the stocks along with the positions.
With the stocks loaded, we can add the order form above the positions table.
Sending orders
The form should be visible on the main page, but it won’t work just yet.
We need to add a Remix action implementation to handle the POST requests.
However, before we get into that, let’s add a new function to trade.server.ts
for sending orders, which the action handler will be able to make use of.
This wrapper function also massages the error response when the
request fails due to insufficient positions.
Handling form submissions
Let’s create an action handler and call our new sendOrder function
from the handler.
We have also added some rudimentary error reporting to the UI.
This reports issues with insufficient positions to the user.
Conclusion
This was the final post in the series.
We have finished a simple, but intentionally a little over-engineered,
trading app.
The business logic was implemented in Rust, with the persistence layer
built on top of Postgres. Type compatibility between the Postgres schema and
Rust code was enforced through the use of SQLx.
The service layer was built using tonic, a gRPC
library for Rust. Using tonic, the Rust types are auto-generated from protobuf files.
Rust’s strict compiler ensures that the types between our business layer and API layer
are consistent.
We implemented our frontend using Remix with TypeScript. The Remix service talks to the Rust service
using gRPC. The TypeScript types were auto-generated from the proto type definitions
provided by the Rust service. These types were shared between the client-side and server-side
code in the Remix app. We even used zod to validate our forms against
the auto-generated types.
With this approach, we ensured type consistency from the Postgres database all the way
to the frontend views. Whilst this application may come across as a contrived
example, the type safety provided by this stack (and similar stacks - variations include code generation
through OpenAPI or GraphQL)
starts to shine and add real value in larger projects.
The resulting code can be found in the v4-remix-app
branch on GitHub.