2022-12-04
11 min read
Building Fullstack React Apps with Ultra
Ultra is a full stack framework for building Deno webapps using React. Ultra recently released version 2.0 that is more customizable than v1.
Ultra works by streaming React-generated HTML markup from the server. Version 2 of the app is designed to run under React 18+ which supports React Suspense. Suspense allows asynchronous loading of components that need to do some time-intensive work on the server -- such as data fetching -- before they are rendered. The React.Suspense
component provides a fallback
prop to display a loading indicator component before the suspended child component is rendered.
This article will demonstrate how to use Ultra to create and deploy a React app. Source code can be found in this Github repo and the application is deployed on Deno Deploy here.
Creating an Ultra App
The best way to create a Ultra project is to use the create CLI script
(create.ts
). You run it by invoking:
deno run -A -r https://deno.land/x/ultra/create.ts
The script will then ask for the project name and whether you want to use TypeScript or JavaScript. The project name will become the folder name where the project exists under the folder where you ran the script, so it's best not to have any spaces in the project name.
The script will then request what libraries you would like to use with your Ultra app. The options are for:
- Styling: Tailwind (twind), Stitches or no CSS library. Static CSS is still an option as the script will create a
style.css
file. - Routing: React Router, Wouter or no routing are the options.
- HTML head management: To use React Helmet or nothing. If nothing is chosen, HTML is added to the return value of the
App
component insideapp.tsx
with HEAD and BODY elements. - Data access: React Query or no data access library.
Other React libraries still can be used in a an Ultra app, These libraries and other options are demonstrated in the examples
folder of the Ultra repo.
Key app files and folders
The key files and folders in an Ultra app include code to compile and run the app, and setup code for many libraries so it is important to know what is going on with them.
They include:
importMap.json
: The HTML-standard import map that allows an alias to be used in place of a JavaScript/TypeScript url in an ECMAScript import statement.deno.json
: The Deno config file used for various things including command line scripts (like the scripts section ofpackage.json
) under the"tasks"
property. Invoke a task using thedeno task <task name>
command. Make sure you reference the path to the app's import map as the value of the"importMap"
property. Also, with the"lint"
and"fmt"
properties you'll want to exclude the.ultra
folder.server.ts
: This is the app's entry point as it is invoked on the Deno command line when the"dev"
and"start"
tasks are run. It works with theHono
server library. React Router and other libraries need to have setup code put in this file. This can be tricky which is why I suggest using thecreate.ts
app to add libraries.client.ts
: This is the client's entry point that hydrates the app when it is rendered in the browser. TheClientApp
function returns the home page with components and should be wrapped in context providers needed for the app to function.build.ts
: Used to build the app and dependencies into a.ultra
folder. Add any files you do not want to be deployed to thebuilder.ignore
call in that file. Also put code into this file for any additional build compilation or transformations. For instance, an MDX compile step is done in thewith-mdx
example. Also note build options detailed in thelib/build/types.ts
file in the Ultra repo.src/app.tsx
: The React app's entry point. Thecreate.ts
script will generate anApp
component in this file that returns example content wrapped in an HTML tag that includes HEAD and BODY elements.public folder
: This app holds the app's assets including images, CSS files and other static content..ultra folder
: Holds the result of an Ultra build that will get deployed to production.
Demo app
I created a TypeScript app that used Tailwind, React Router and React Query. I employed the create script to scaffold out the app which adds context providers for React Router and React Query to server.ts
and/or client.ts
.
My app uses the jsonplaceholder API with React Query to display fake users and blog posts. You can see it in action here. To run the app locally invoke deno task dev
from the command line. Find the app's code in this repo.
React Router
The React Router
(v6) context provider was setup in both server.ts
and client.ts
. Note that the StaticRouter
is used for server-side rendering while the client file uses BrowserRouter
.
The routes for both server and client are configured in app.tsx
. Here's what that looks like:
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="about" element={<AboutPage />} />
<Route path="user_details/:userId" element={<UserDetailsPage />} />
<Route path="*" element={<RouteNotFound />} />
</Route>
</Routes>
Each of the routes point to a page component that wraps the page's content. The Layout
component defines a Layout Route which forms a shell around other components containing the app's header and footer. Routes defined inside the Layout Route hold the content (HomePage
, AboutPage
, UserDetailsPage
and RouteNotFound
page in this case).
The Layout
component uses an Outlet
component to defined where the child components go as the children
prop did previously. This is what that component's return value looks like (styling removed):
<>
<header>
<nav>
<NavLink to="/">Home</NavLink>
<NavLink to="/about">About</NavLink>
</nav>
<div>
<h1>Ultra Demo App</h1>
</div>
</header>
<main>
<Outlet/> {/* Child components here */}
</main>
<footer>
<div>
<a href="https://ultrajs.dev">
Built with Ultra
</a>💎
</div>
</footer>
</>
See the React Router docs for more details on version 6 of the library.
React Query
ReactQuery
is a data management API used in the app to make API calls for user data. It provides intelligent caching, prefetching and pagination features among others. Version 4 also supports React suspense for asynchronous data fetching.
React Query setup in Ultra is somewhat complicated, so it is advised that you bring it in when running the create.ts
project-creation script. You'll noticed that a src/react-query
folder has been created. The query-client.ts
file inside that folder initializes a QueryClient
class containing a suspense: true
option for React suspense support.
The useDehydrateReactQuery.tsx
file in the react-query
folder uses the helper hook useServerInsertedHTML
included in the Ultra distribution. The useDehydrateReactQuery
function serializes the query client's fetched data on the server side storing it in a window
property called __REACT_QUERY_DEHYDRATED_STATE
. This is all done when server.tsx
is invoked at app startup.
Query data rehydration is done in client.tsx
using the Hydrate
component from React Query.
// client.ts
import { Hydrate } from "@tanstack/react-query";
This component is added to the JSX returned from the ClientApp()
function:
// Other code is missing
<Hydrate state={__REACT_QUERY_DEHYDRATED_STATE}>
<BrowserRouter>
<App />
</BrowserRouter>
</Hydrate>
Recall that __REACT_QUERY_DEHYDRATED_STATE
was populated in server.tsx
.
QueryClientProvider
, react query's context provider, is added to both the client (client.ts
) and server (server.ts
) file. It is imported from @tanstack/react-query
.
Tailwind
The popular Tailwind
CSS class collection is supported by the twind library. Twin
compiles Tailwind CSS classes into generic CSS on the fly so it is ideal for Ultra's streaming server.
Tailwind classes are expressed using the tw
function. Here is an example how it is used:
import { tw } from "./twind/twind.ts";
// other code here
<h2 className={tw(`text-3xl font-bold`)}>
About this Site
</h2>
Check the Tailwind docs for details on the available Tailwind
classes. Support for Twind
1.0 was recently added to Ultra which is compatible with Tailwind
version 3.
Using Suspense
Ultra version 2 works with React v18. A big feature of this new React version is suspense. React suspense allows a component to be asynchronously rendered. This means that part of the UI can be displayed while suspended components are still being rendered.
Setting up suspense involves wrapping a component with the Suspense
component. This is what that looks like:
// Home.tsx
import { Suspense } from "react";
// other code here...
<Suspense fallback=
{<div>Page is Loading...</div>}
>
<UserList />
</Suspense>
In this case the UserList
component is being suspended. Note the fallback
prop that is used to define a component that will be displayed while the suspended component is still being rendered. Once that is completed, the suspended component will replace the fallback component.
When a Suspense
component is used in an application page, the page needs to be lazy loaded with a dynamic import:
// app.tsx
const HomePage = lazy(() => import("./pages/Home.tsx"));
Hono
As noted above Ultra uses the Hono Deno server under the covers. Hono's Deno server is based on the http server in the Deno standard library. But Hono adds value that can be used in an Ultra app. A big one is middleware.
The createServer
call in server.tsx
returns a Hono server object in a variable called server
. Middleware is added with a call to server.get
. The first argument is a string representing an http path. The second argument is a handler function with a Context
first argument and next
function as the second argument.
The handler function can either return a Response
or Promise<Response | undefined | void>
or invoke the next
function using an await
since next
returns a Promise
. The next
function passes the request flow onto the next server.get
located in server.tsx
(see the definition of Handler
in src/types.ts
in the Hono repo and the Middleware docs).
Here is an example of middleware that adds a header to the response called "Server" with a value of "Ultra Hono":
server.use('*', async (c, next) => {
c.res.headers.set("Server", "Ultra Hono");
await next();
});
Hono provides a bunch of built-in middleware functions including ones for authentication, CORS support and serving static files.
Hono can also be used for server-side routing. If authentication middleware is used, then server-side routing is required.
You can create an API route using Hono too. See the with-api-routes Ultra example to learn how this is done in addition to the Hono routing docs.
Note that the Hono API supports Node and Cloudfare Workers in addition to Deno. See the Hono Deno docs for more details on the Hono Deno server.
Other Libraries
There are over 20 examples in the Ultra repo's examples folder. Most of them show how to use React libraries with Ultra. They include (besides libs detailed above):
- Material UI: A collection of React components.
- tRCP: a library for creating type-safe APIs.
- mdx: converts markdown into JSX content.
- emotion: a CSS-in-JS library.
- react-helmet: a component to add an HTML Head element to a JSX page.
- with-preact: Preact is a lightweight React port. Note that not all React libraries work with Preact.
- static HTML: See the
examples/bogus-marketing-or-blog
folder. Note thegenerateStaticHTML
anddisableHydration
properties added to theserver.render
function call inserver.tsx
- island architecture - this structures the app where islands of JavaScript-related reactivity are surrounded by static HTML content. It is how Deno Fresh works.
When adapting one of these examples to your application, pay particular attention to changes in server.tsx
, client.tsx
and sometimes build.ts
that allows the example to work with Ultra.
Deployment
There are two main options for deployment an Ultra app, using Docker to deploy to cloud hosts that support it like fly.io and Deno Deploy. Instructions are found in the Ultra deployment docs.
When using Deno Deploy, you need to set inlineServerDynamicImports: true
as a createBuilder
option in build.ts
since Deploy does not support dynamic imports. Also note the Github Action needed to use Deno Deploy with Ultra.
Production deployment requires that all the images and other assets are wrapped in a useAsset
hook from Ultra like this:
import useAsset from "ultra/hooks/use-asset.js";
// other code here
<link rel="shortcut icon" href={useAsset("/favicon.ico")} />
The useAsset
hook is used to version the asset during a production build.
To build and dry-run the app before production deployment, invoke the build
task locally and then run the start
task inside the .ultra
folder.
Conclusion
Ultra is the third most popular Deno web framework next to Fresh and Aleph which are both supported by the Deno team. It is the only one of the three that focusses on React and does a good job at supporting React libraries and modern practices.
Ultra evolved dramatically between version 1 and 2 and its development continues to accelerate. When this post was published Ultra's current version was v2.1.4, so be aware that there might be some changes at a future date.
At any rate, Ultra is a good option for creating React apps because it eliminates the build step with it's headache-inducing configurations therefore allowing you to focus on application development.
Finally, make sure you check out my Ultra demo app repo and the examples folder in the Ultra repo for more ideas on how to use Ultra to create a Deno/React app.