Mountaineer v0.1: Webapps in Python and React
# February 27, 2024
Today I'm really excited to open source a beta of Mountaineer, an integrated framework to quickly build webapps in Python and React. It's initial goals are quite humble: make it really pleasurable to design systems with these two languages.
Mountaineer provides the following key features:
- First-class typehints for the frontend, backend, and database. Auto-suggest all data and function signatures for ease of development in your IDE.
- Trivially simple client<->server data binding, function calling, and error handling.
- Server rendering of frontend components, using a bundled V8 engine.
- Static analysis of frontend code for strong validation of types and links to other pages.
- PostCSS support for Tailwind, CSS Polyfills, etc.
It doesn't shoot for anything too custom. You don't write Python code to be compiled into React components. It doesn't spin up a new intermediary server to route requests. It's just vanilla Python and vanilla React. The framework focuses on helping them both play to their strengths while interoperating seamlessly.
If you're itching to see code, either head over to the Github or skip down to the Walkthrough. Before that, I want to talk a little about how I got here.
Background
Another framework? We're drowning in them already!
- Abraham Lincoln
I've built upwards of 25 separate webapps over the last 5 years. Some were in an R&D capacity at Globality, many were side projects, and some were for MonkeySee's core product. Some backends have been in Python, others in Golang, some in Node, and others in Rust. I've poked around the frontend scene as well before eventually landing on Next.js with React.1
I really liked Next. It's fast to prototype, fast to deploy, and lets you develop complex UI interactions easily. But throughout using Javascript for server-side development, I always hit walls while trying to make it do what I wanted. Classes weren't a first class primitive until ES6! Type annotations aren't interpretable at runtime! Node has always felt like a language that already had adoption client-side, so it expanded in feature-set to do more server-side. Python has felt like a language that was born on desktops, for the desktop, and expanded its feature set because people loved the primitives baked into the language design.2
Instead of server-side Node, my typical stack these days is: a Python backend, a simple Next.js gateway and frontend host, and a React codebase client-side.
I want to be clear. All this is personal preference, not some dogma about language design. But I've tried to interrogate why I feel most productive in Python and React (and why it seems like many others agree). I think most of it comes down to expectations - by users about websites and implementers about their companies.
-
Users: As the web evolved, people's expectations of it grew. Users now expect a baseline of interactivity from webpages; they want overlay popovers, inline data validation, async data fetching, and virtualized table views with reams of data. This interaction is easier to model with a declarative virtual DOM - you write components once and let the runtime change them depending on variable state.
-
Users: Speed isn't the most important priority. We're acclimated to having to wait for websites, because we recognize there is going to be some network latency.3 With the speed of modern hardware, combined with the pretty simple CRUD commands necessary to serialize data, an interpreted language can deliver performance that still far exceeds user expectations.
-
Implementers: I imagine the majority of webapps are written for startups: either as their core offering or as internal tools. Fast-to-prototype languages let you hear from clients about what they want and change your tact as you learn from the market. This speed is especially worth a premium when you're competing against incumbents that need to move slowly.
Python and React facilitate these tradeoffs as part of language design:
- They're optionally typed. You can whip up a component really quickly without having to worry about memory management, reference passing, or correctly typehinting signatures.4
- Have a large standard library (in the case of Python especially) and a rich ecosystem of 3rd party packages for where the stdlib falls short.
- Greedily executed. You don't have to worry about other pieces of the codebase when you're just focused on one component. For webapps with large scope and a lot of different pages, you just focus on the parts you're touching and testing.
All of these have downsides. Lack of consistent typing, supply-chain vulnerabilities, and delaying errors until runtime all being key ones. But net-net, I think the juice is worth the squeeze.
Introducing Mountaineer 🏔️
Mountaineer is built on a few core ideas for how webapps tend to work.
- Your database tables contain the "rawest" form of your data. It's the ground truth source but also the least self-explanatory.
- The backend fetches data from this database and does some additional rollup to make it more accessible to clients. It then passes on this data to the frontend for the initial render.
- Most of this data stays the same for the lifecycle of the page, but users can manipulate some of it. They can edit values, create new objects, etc.
- These manipulations need to update the data in the database. The frontend communicates what it wants changed, the API validates it, then the backend makes the proper modifications to the database.
- This in turn starts the cycle over again, where the frontend state now has to be updated from the updated server state.
This cycle is implemented again and again through increasingly complex layers. At the end of the day, you have data that needs to come from the database and data that needs to go to the database. Depending on the data, this cycle might start over again. That's all most webapps are.5
Mountaineer models this data cycle (frontend<->backend) as a core part of its logic. Instead of specifying an API just for frontend consumers, you specify the data and actions that your frontend will need to function. These are then created and injected dynamically to your frontend, so they end up looking like regular runtime objects that have always lived on the client side. On initial load, these come directly from the server. During data modification, these are synchronized via fetch().
Let's take a closer look.
Mountaineer is a MVC inspired architecture, so each controller needs a view - and each view a controller.6 The project scaffolding is a regular Python project that includes a separate "views" folder that defines your React application. Your average controller will look like this:
```python
from mountaineer import sideeffect, ControllerBase, RenderBase
from mountaineer.database import DatabaseDependencies
from fastapi import Request, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from my_webapp.models.todo import TodoItem
class HomeRender(RenderBase):
todos: list[TodoItem]
class HomeController(ControllerBase):
url = "/"
view_path = "/app/home/page.tsx"
async def render(
self,
session: AsyncSession = Depends(DatabaseDependencies.get_db_session)
) -> HomeRender:
todos = await session.execute(select(TodoItem))
return HomeRender(
todos=todos.scalars().all()
)
@sideeffect
async def add_todo(
self,
payload: NewTodoRequest,
session: AsyncSession = Depends(DatabaseDependencies.get_db_session)
):
new_todo = TodoItem(description=payload.description)
session.add(new_todo)
await session.commit()
In the controller, render()
will provide the initial data that is accessible to your view on load. It accepts all the function signatures that FastAPI supports: query parameters, url path variables, dependency injection, and more. render
is called whenever your page is loaded and lets you fetch the data necessary to prepare the view.
The @sideeffect
decorator will generate a POST API that the frontend client can call to influence server state. Except instead of a regular POST, it will perform your logic, then re-call render()
to get the latest state from the database and push this back to the client.
A matching frontend view will look like:
import React from "react";
import { useServer, ServerState } from "./_server/useServer";
import CreateTodo from "./create_todo";
const Home = () => {
const serverState = useServer();
return (
<div>
<p>
Hello, you have {serverState.todos.length} todo items.
</p>
<CreateTodo serverState={serverState} />
{
/* Todo items are exposed as typehinted Typescript interfaces */
serverState.todos.map((todo) => (
<div key={todo.id}>
<div>{todo.description}</div>
</div>
))
}
</div>
);
};
export default Home;
The _server
import is the first indication that this is a Mountaineer project. Every file in _server
is automatically generated for you by the local development server. You can also build it manually for use in CI. This page just echos the data provided by render()
and includes a component to create a new todo item. You can import components and pass the server state as you expect with a regular React project:
import React, { useState } from "react";
import { useServer, ServerState } from "./_server/useServer";
const CreateTodo = ({ serverState }: { serverState: ServerState }) => {
const [newTodo, setNewTodo] = useState("");
return (
<div>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
/>
<button
onClick={
/* Here we call our sideeffect function */
async () => {
await serverState.add_todo({
requestBody: {
description: newTodo,
},
});
setNewTodo("");
setShowNew(false);
}
}
>
Create
</button>
</div>
);
};
export default CreateTodo;
Out of the box, you can run a local server with the following:
poetry run runserver
With every update to your server definitions, Mountaineer will build your managed client dependencies in Typescript. To you - and your IDE - it will look like you just have a few more typehints that are always in sync with the server definitions.
Your entrypoint to server data is the useServer()
hook. Included within this object are all the bits of data that come from the server, all the actions you're allowed to perform on the client side, and some helpful functions to generate links or check for errors. This is what it looks like when evaluated:
> console.log(serverState)
Object {
todos: [...]
add_todo: async (...args) => {…}
linkGenerator: {
detailController: ({ detail_id }) => {…}
homeController: ({}) => {…}
}
}
You can use these variables just like they're any other React objects. Insert them into the view, apply a map on top of them, subscribe to changes in useEffect
, etc. Mountaineer will initially render these on the server side when the client loads your view - returning plain html to the browser. This html is then hydrated with React so you get interactivity once Javascript has loaded.
Internal architecture
While Python and React are the key development languages for end developers, Mountaineer internally uses Rust for logic in the hot-path that benefits from compiled optimizations. Right now we have spent a lot of upfront time optimizing the server-side rendering flow, alongside sourcemap parsing. As the project matures, more core code will be moved into Rust to achieve speed gains on the repetitive logic.
The server layer is built on top of FastAPI and Pydantic, to leverage their solid typehinting primitives and plug into the existing Starlette ecosystem. This gives you the ability to serve your webapp with gunicorn or any other compatible host in production.
The two top design goals for Mountaineer at the moment are:
- Best developer UX possible, embracing Python and React conventions
- Production speed to handle your first user and grow to scale
Head on over to the Github for more technical details.
Where we are
Mountaineer is really new. That said, it's already being dogfooded aggressively. It's hosting all our internal webapps at MonkeySee and being prototyped by some others. My goal at the moment is to make the core Mountaineer codebase as robust and fast as possible. After the API crystallizes then add some common plugins.
If you're interested in a stack focused on Python and React, please take Mountaineer for a spin. It should already be powerful enough to implement a wide variety of webapps on top of it. Let us know what it's missing and what would give you further superpowers in your webapp workflow.
If you're interested in helping out with development, join me on Github. There's no shortage of things to do. I say this with some obvious bias - but this has been the most fun codebase I've worked on in a while.
Let's get shipping.
-
Ah, the good old days when the flame wars were just Angular vs. React. Now it's Svelte vs. Vue vs. SolidJS, etc. That's not to mention all the frameworks that are built out on top of each of these.
Do you smell that? This post is burning already. ↢ -
This is overly simplistic, I know. But it rings true. ↢
-
The state of our bloated ad-tracker ecosystem where the initial load of a blog article might be 30MB doesn't help here either. ↢
-
I say this even as someone who loves Rust. I love Rust's memory guarantees, handling of macros, and aggressive typing. Mostly I love its speed. But the diligence required to achieve that speed can butt heads against the cultural development cycle of webapps. ↢
-
So much effort has been spent to let backends develop organically of their frontend. I've never seen this work well. Attempting to "decouple" frontend systems from its backend leads to frustrating development flows - because by definition they are coupled. Embracing the coupling lets you make a lot more assumptions and abstract away a lot more annoyances. I say let the great re-coupling begin. ↢
-
Together there is harmony. ↢