Skip to content

A framework of architectural decisions that makes the codebase easy to maintain.

Notifications You must be signed in to change notification settings

asasvirtuais/asasvirtuais-framework

Repository files navigation

asasvirtuais

A React framework for building maintainable web applications without the architectural debt.

After 7 years of wrestling with complex tech stacks, I built asasvirtuais to solve a problem nobody seems to talk about: the elephant under the carpet of modern web development. Every framework gives you components and state management, but none of them solve the fundamental challenge every project faces—connecting CRUD APIs to UI forms with clean, maintainable state management.

This isn't about fancy animations or advanced performance optimization. This is about making codebases simple enough that you (or an AI) can focus on business logic instead of wrestling with architectural patterns.

The Problem

Software development has convinced itself that complexity is inevitable. We've been taught that proper applications require:

  • State scattered across dozens of files
  • Design patterns that make simple things complicated
  • Dependencies injected through layers of abstraction
  • Code that's impossible to reason about without opening 10 files

But here's the thing: complexity exists, but overengineering is a human tendency, not a technical requirement.

The Solution

asasvirtuais is built on a simple foundation: React + RESTful APIs. No magic, no over-abstraction. Just a library that makes the right architectural decisions obvious.

The core insight: developers and AI shouldn't need to think about state management—just focus on business logic.

What Makes This Different

  1. Nested forms that actually make sense - Build multi-step async validation workflows without the pain
  2. CRUD operations as a solved problem - Filter, create, update with zero boilerplate
  3. Code in one place - Business logic lives in readable, single files, not scattered across a dependency tree
  4. AI-friendly patterns - Simple enough that AI can generate complex forms correctly on the first try

Installation

From npm

npm install asasvirtuais

From esm.sh

import { Form } from 'https://esm.sh/asasvirtuais@latest/form'
import { useFields } from 'https://esm.sh/asasvirtuais@latest/fields'
import { useAction } from 'https://esm.sh/asasvirtuais@latest/action'

Quick Start

Simple Form

import { Form } from 'asasvirtuais/form'

type LoginFields = {
  email: string
  password: string
}

type LoginResult = {
  token: string
  user: { id: string; name: string }
}

async function loginAction(fields: LoginFields): Promise<LoginResult> {
  const response = await fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify(fields)
  })
  return response.json()
}

function LoginForm() {
  return (
    <Form<LoginFields, LoginResult>
      defaults={{ email: '', password: '' }}
      action={loginAction}
      onResult={(result) => console.log('Logged in:', result.user.name)}
    >
      {({ fields, setField, submit, loading, error }) => (
        <form onSubmit={submit}>
          <input
            type="email"
            value={fields.email}
            onChange={(e) => setField('email', e.target.value)}
          />
          <input
            type="password"
            value={fields.password}
            onChange={(e) => setField('password', e.target.value)}
          />
          <button type="submit" disabled={loading}>
            {loading ? 'Logging in...' : 'Login'}
          </button>
          {error && <p>Error: {error.message}</p>}
        </form>
      )}
    </Form>
  )
}

Core Concepts

1. Forms: The N8N for React

Think of forms like nodes in a visual workflow builder. Each form is self-contained, knows its state, and can trigger actions. Nest them to create complex workflows without state management headaches.

// Multi-step form with async validation between steps
<Form<EmailFields, EmailResult>
  defaults={{ email: '' }}
  action={checkEmail}
>
  {(emailForm) => (
    <div>
      <input
        value={emailForm.fields.email}
        onChange={(e) => emailForm.setField('email', e.target.value)}
      />
      <button onClick={emailForm.submit}>Next</button>

      {emailForm.result?.exists && (
        <Form<PasswordFields, PasswordResult>
          defaults={{ userId: emailForm.result.userId, password: '' }}
          action={verifyPassword}
        >
          {(passwordForm) => (
            <input
              type="password"
              value={passwordForm.fields.password}
              onChange={(e) => passwordForm.setField('password', e.target.value)}
            />
          )}
        </Form>
      )}
    </div>
  )}
</Form>

2. Fields: State Without the Ceremony

Need just state management? Use FieldsProvider:

import { FieldsProvider, useFields } from 'asasvirtuais/fields'

function ProfileEditor() {
  return (
    <FieldsProvider<ProfileFields> defaults={{ name: '', bio: '' }}>
      {({ fields, setField }) => (
        <div>
          <input
            value={fields.name}
            onChange={(e) => setField('name', e.target.value)}
          />
          <textarea
            value={fields.bio}
            onChange={(e) => setField('bio', e.target.value)}
          />
        </div>
      )}
    </FieldsProvider>
  )
}

3. Actions: Async Operations Made Simple

Need just action handling? Use ActionProvider:

import { ActionProvider } from 'asasvirtuais/action'

function DeleteButton({ userId }: { userId: string }) {
  return (
    <ActionProvider
      params={{ userId }}
      action={deleteAccount}
      onResult={() => alert('Account deleted')}
    >
      {({ submit, loading }) => (
        <button onClick={submit} disabled={loading}>
          {loading ? 'Deleting...' : 'Delete Account'}
        </button>
      )}
    </ActionProvider>
  )
}

React Interface: Data-Driven Applications

The react-interface package provides components and hooks for building data-driven React apps. Define your schema once, and use the components directly—no initialization needed.

Complete Todo App Example

1. Define Your Schema

// app/database.ts
import { z } from 'zod';

export const todoSchema = {
  readable: z.object({
    id: z.string(),
    text: z.string(),
    completed: z.boolean(),
    createdAt: z.date(),
  }),
  writable: z.object({
    text: z.string(),
    completed: z.boolean().optional(),
  }),
}

// You can export multiple schemas
export const userSchema = {
  readable: z.object({
    id: z.string(),
    name: z.string(),
    email: z.string(),
  }),
  writable: z.object({
    name: z.string(),
    email: z.string(),
  }),
}

2. Provide the Interface Context

// app/layout.tsx
import { InterfaceProvider } from 'asasvirtuais/fetch-interface'
import { DatabaseProvider, TableProvider } from 'asasvirtuais/react-interface'
import { todoSchema } from './database'

export default function RootLayout({ children }) {
  return (
    <DatabaseProvider>
      <InterfaceProvider schema={todoSchema} baseUrl='/api/v1'>
        <TodosProvider>
          {children}
        </TodosProvider>
      </InterfaceProvider>
    </DatabaseProvider>
  )
}

3. Create Your Table Provider

// app/todos/provider.tsx
'use client'
import { TableProvider } from 'asasvirtuais/react-interface'
import { useInterface } from 'asasvirtuais/fetch-interface'
import { todoSchema } from './database'

export function TodosProvider({ children }) {
  return (
    <TableProvider
      table='todos'
      schema={todoSchema}
      interface={useInterface()}
    >
      {children}
    </TableProvider>
  )
}

4. Build Your UI

// app/todos/page.tsx
'use client'
import { useDatabaseTable, CreateForm } from '@asasvirtuais/react-interface'
import { todoSchema } from '@/app/database'

function TodoList() {
  const { index, remove, update } = useDatabaseTable('todos')
  const todos = Object.values(index.index)

  return (
    <>
      <CreateForm
        table="todos"
        schema={todoSchema}
        defaults={{ text: '' }}
      >
        {({ fields, setField, submit, loading }) => (
          <form onSubmit={submit}>
            <input
              value={fields.text}
              onChange={(e) => setField('text', e.target.value)}
              placeholder="What needs to be done?"
            />
            <button type="submit" disabled={loading}>
              {loading ? 'Adding...' : 'Add Todo'}
            </button>
          </form>
        )}
      </CreateForm>

      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <span
              style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
              onClick={() => update.trigger({ 
                id: todo.id, 
                data: { completed: !todo.completed } 
              })}
            >
              {todo.text}
            </span>
            <button onClick={() => remove.trigger({ id: todo.id })}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </>
  )
}

5. Multiple Tables with DatabaseProvider

For apps with multiple tables, wrap them all in a DatabaseProvider:

// app/layout.tsx
import { DatabaseProvider, TableProvider } from '@asasvirtuais/react-interface'
import { todoSchema, userSchema } from './database'
import { todosInterface, usersInterface } from './interface'

export default async function RootLayout({ children }) {
  const [initialTodos, initialUsers] = await Promise.all([
    fetchTodos(),
    fetchUsers()
  ])
  
  return (
    <DatabaseProvider>
      <TableProvider table="todos" schema={todoSchema} interface={todosInterface} asAbove={initialTodos}>
        <TableProvider table="users" schema={userSchema} interface={usersInterface} asAbove={initialUsers}>
          {children}
        </TableProvider>
      </TableProvider>
    </DatabaseProvider>
  )
}

// Now any component can access tables:
function MyComponent() {
  const todos = useDatabaseTable('todos')
  const users = useDatabaseTable('users')
  // ...
}

Advanced Examples

Multi-Step Address Validation

// Complete checkout flow with async address lookup
<Form<AddressLookupFields, AddressLookupResult>
  defaults={{ zipCode: '' }}
  action={lookupAddress}
>
  {(zipForm) => (
    <div>
      <h3>Enter ZIP Code</h3>
      <input
        value={zipForm.fields.zipCode}
        onChange={(e) => zipForm.setField('zipCode', e.target.value)}
      />
      <button onClick={zipForm.submit}>Lookup Address</button>

      {zipForm.result && (
        <Form<FullAddressFields, OrderResult>
          defaults={{
            zipCode: zipForm.fields.zipCode,
            city: zipForm.result.city,
            state: zipForm.result.state,
            country: zipForm.result.country,
            street: '',
            number: ''
          }}
          action={createOrder}
          onResult={(result) => alert(`Order created: ${result.orderId}`)}
        >
          {(addressForm) => (
            <div>
              <h3>Complete Address</h3>
              <p>City: {addressForm.fields.city}</p>
              <p>State: {addressForm.fields.state}</p>
              <input
                value={addressForm.fields.street}
                onChange={(e) => addressForm.setField('street', e.target.value)}
                placeholder="Street"
              />
              <input
                value={addressForm.fields.number}
                onChange={(e) => addressForm.setField('number', e.target.value)}
                placeholder="Number"
              />
              <button onClick={addressForm.submit}>Place Order</button>
            </div>
          )}
        </Form>
      )}
    </div>
  )}
</Form>

Effects and Side Effects

One of asasvirtuais's core strengths is making effects simple. No middleware arrays, no lifecycle hooks—just write code where it belongs.

Frontend Effects (React)

In React, you control exactly when effects happen by writing code around form actions.

Pre-flight Effects

Run code before submission:

import { CreateForm } from '@asasvirtuais/react-interface'
import { messageSchema } from '@/app/database'

<CreateForm
  table="messages"
  schema={messageSchema}
  defaults={{ content: '' }}
>
  {({ fields, setField, submit, loading }) => (
    <form onSubmit={submit}>
      <textarea
        value={fields.content}
        onChange={(e) => setField('content', e.target.value)}
      />
      <button
        onClick={() => {
          // Pre-flight effect - runs before submit
          trackEvent('message_submit_clicked')
          validateContent(fields.content)
          submit()
        }}
        disabled={loading}
      >
        Send
      </button>
    </form>
  )}
</CreateForm>

Post-flight Effects

Run code after successful submission:

<CreateForm
  table="messages"
  schema={messageSchema}
  defaults={{ content: '' }}
  onSuccess={(message) => {
    // Post-flight effects - run after success
    notifyUser('Message sent!')
    scrollToBottom()
    trackAnalytics('message_created', { id: message.id })
  }}
>
  {({ fields, setField, submit }) => (
    <form onSubmit={submit}>
      {/* form fields */}
    </form>
  )}
</CreateForm>

Using Field Values Without Submitting

Sometimes you want to use the form's field values without calling the server action:

<CreateForm
  table="messages"
  schema={messageSchema}
  defaults={{ content: '' }}
>
  {(form) => (
    <div>
      <textarea
        value={form.fields.content}
        onChange={(e) => form.setField('content', e.target.value)}
      />
      
      {/* This button calls the server action */}
      <button onClick={form.submit}>Send to Server</button>
      
      {/* This button uses field values without calling the action */}
      <button onClick={() => {
        // Just use the field values directly for local operations
        saveToLocalStorage(form.fields)
        showPreview(form.fields)
      }}>
        Save Draft Locally
      </button>
    </div>
  )}
</CreateForm>

Backend Effects (API Routes)

On the backend, effects are just functions wrapping other functions. No framework magic.

Using tableInterface for Business Logic

// app/api/v1/[...params]/route.ts
import { tableInterface } from '@asasvirtuais/interface'
import { firestoreInterface } from '@/lib/firestore'
import { messageSchema } from '@/app/database'

// Wrap your base interface with business logic
const messagesInterface = tableInterface(messageSchema, 'messages', {
  async create(props) {
    // Pre-flight validation
    await checkUserQuota(props.data.userId)
    await moderateContent(props.data.content)
    
    // The actual database operation
    const message = await firestoreInterface.create(props)
    
    // Post-flight side effects
    await updateConversationTimestamp(message.conversationId)
    await notifyParticipants(message.conversationId, message.id)
    await trackMessageCreated(message)
    
    return message
  },
  
  async update(props) {
    const existing = await firestoreInterface.find(props)
    
    // Business rule enforcement
    if (existing.role === 'assistant') {
      throw new Error("Cannot edit assistant messages")
    }
    
    if (existing.userId !== getCurrentUserId()) {
      throw new Error("Cannot edit other users' messages")
    }
    
    return firestoreInterface.update(props)
  },
  
  async remove(props) {
    const message = await firestoreInterface.find(props)
    
    // Cascade deletion
    await deleteMessageAttachments(message.id)
    await updateConversationCount(message.conversationId, -1)
    
    return firestoreInterface.remove(props)
  },
  
  // Pass through operations that don't need custom logic
  find: firestoreInterface.find,
  list: firestoreInterface.list,
})

Key Principles

  1. Effects are just code - No special lifecycle methods or middleware patterns
  2. Control flow is visible - Reading the code tells you exactly what runs and when
  3. Composition over configuration - Wrap functions to add behavior, don't configure hooks
  4. Backend and frontend mirror each other - The same compositional patterns work everywhere

API Reference

Form<Fields, Result>

Combined fields and action management.

Props:

  • defaults?: Partial<Fields> - Initial field values
  • action: (fields: Fields) => Promise<Result> - Async action to perform
  • onResult?: (result: Result) => void - Success callback
  • onError?: (error: Error) => void - Error callback
  • autoTrigger?: boolean - Auto-trigger action on mount
  • children: ReactNode | (props) => ReactNode - Render prop or children

Render Props:

  • fields: Fields - Current field values
  • setField: (name, value) => void - Update single field
  • setFields: (fields) => void - Update multiple fields
  • submit: (e?) => Promise<void> - Trigger action
  • loading: boolean - Action loading state
  • result: Result | null - Action result
  • error: Error | null - Action error

FieldsProvider<T>

Field state management only.

Props:

  • defaults?: Partial<T> - Initial field values
  • children: ReactNode | (props) => ReactNode - Render prop or children

Hook: useFields<T>()

  • fields: T - Current field values
  • setField: (name, value) => void - Update single field
  • setFields: (fields) => void - Update multiple fields

ActionProvider<Params, Result>

Action management only.

Props:

  • params: Partial<Params> - Action parameters
  • action: (params) => Promise<Result> - Async action
  • onResult?: (result: Result) => void - Success callback
  • onError?: (error: Error) => void - Error callback
  • autoTrigger?: boolean - Auto-trigger on mount
  • children: ReactNode | (props) => ReactNode - Render prop or children

Hook: useAction<Params, Result>()

  • params: Partial<Params> - Current parameters
  • submit: (e?) => Promise<void> - Trigger action
  • loading: boolean - Loading state
  • result: Result | null - Action result
  • error: Error | null - Action error

Philosophy

Code Maintainability Over Everything

The industry has normalized spreading code across dozens of files with dependency injection, decorators, and "clean architecture" patterns that make simple things complicated. asasvirtuais takes the opposite approach:

Keep business logic in single, readable files.

When you can see all the logic in one place, you can reason about it. When logic is scattered, every change becomes archaeology.

Made for Humans and AI

The patterns in asasvirtuais are simple enough that:

  • Junior developers can understand them in minutes
  • Senior developers appreciate the lack of ceremony
  • AI assistants can generate correct implementations on the first try

This isn't about dumbing down—it's about removing accidental complexity.

Against "Babel Towering"

The AI trend seems focused on generating massive codebases quickly, stacking abstraction on abstraction. That's how you build towers that fall.

asasvirtuais is designed for the opposite: codebases that stay maintainable even as they grow.

Real-World Use

I've used asasvirtuais with Airtable for data modeling on production projects. The combination of a simple frontend framework and a flexible backend lets you focus on solving actual problems instead of fighting your tools.

AI Integration

Give AI the asasvirtuais documentation and watch it generate multi-step forms with async validation in a single file—something that would normally require multiple files, complex state management, and careful coordination.

Try it with Google AI Studio.

Model Package Instructions

A model package is a self-contained module that defines a data model and provides React components for interacting with that data. Based on the chat example, here's how to structure a model package:

File Structure

app/[model-name]/
├── index.ts          # Schema definitions and types
├── fields.tsx        # Individual form field components
├── forms.tsx         # Complete form components
└── table.tsx         # Provider and hooks for data access

1. Schema Definition (index.ts)

Define your data model using Zod schemas:

import z from 'zod'

// Define the complete object structure (what comes from the database)
export const readable = z.object({
    id: z.string(),
    // ... other fields that can be read
})

// Define which fields can be written/modified
export const writable = readable.pick({
    // ... fields that can be created/updated
})

// Export the schema object
export const schema = { readable, writable }

// Export TypeScript types
export type Readable = z.infer<typeof readable>
export type Writable = z.infer<typeof writable>

Key Points:

  • readable: Full object schema including id and all readable fields
  • writable: Subset of fields that users can create/modify (typically excludes id)
  • Use .pick() to select fields from readable for writable
  • Export both the schema object and TypeScript types

2. Field Components (fields.tsx)

Create reusable field components for individual form inputs:

import { Input, InputProps } from '@chakra-ui/react'
import { useFields } from 'asasvirtuais/fields'

export function [FieldName]Field(props: InputProps) {
    const { fields, setField } = useFields<{fieldName: type}>()
    
    return (
        <Input 
            name='fieldName' 
            value={fields.fieldName} 
            onChange={e => setField('fieldName', e.target.value)} 
            {...props} 
        />
    )
}

Key Points:

  • Use useFields hook with type annotation matching your field
  • Pass through additional props using spread operator
  • Set appropriate name attribute
  • Handle onChange with setField

3. Form Components (forms.tsx)

Create complete forms for creating and filtering data:

import { CreateForm, FilterForm, useTableInterface } from 'asasvirtuais/react-interface'
import { schema } from '.'
import { [Field]Field } from './fields'
import { Stack, Button } from '@chakra-ui/react'

// Create form
export function Create[Model]() {
    return (
        <CreateForm table='tableName' schema={schema}>
            {form => (
                <Stack as='form' onSubmit={form.submit}>
                    <[Field]Field />
                    <Button type='submit'>Create [Model]</Button>
                </Stack>
            )}
        </CreateForm>
    )
}

// Filter/List form
export function Filter[Models]() {
    const { remove } = useTableInterface('tableName', schema)
    
    return (
        <FilterForm table='tableName' schema={schema}>
            {form => (
                <Stack>
                    {form.result?.map(item => (
                        <div key={item.id}>
                            {/* Display item data */}
                            <Button onClick={() => remove.trigger({id: item.id})}>
                                Delete
                            </Button>
                        </div>
                    ))}
                </Stack>
            )}
        </FilterForm>
    )
}

Key Points:

  • CreateForm handles creation logic, provides form.submit
  • FilterForm provides form.result with filtered data
  • Use useTableInterface for operations like remove
  • Always provide table name and schema to forms

4. Provider and Hooks (table.tsx)

Set up the data provider and custom hooks using InterfaceProvider and useInterface:

'use client'
import { TableProvider, useTableInterface } from 'asasvirtuais/react-interface'
import { useInterface } from 'asasvirtuais/fetch-interface'
import { schema } from '.'

export function use[Models]() {
    return useTableInterface('tableName', schema)
}

export function [Models]Provider({ children }: { children: React.ReactNode }) {
    return (
        <TableProvider
            table='tableName'
            schema={schema}
            interface={useInterface()}
        >
            {children}
        </TableProvider>
    )
}

Then in your layout, wrap with InterfaceProvider to set up the context:

import { InterfaceProvider } from 'asasvirtuais/fetch-interface'
import { DatabaseProvider } from 'asasvirtuais/react-interface'
import { [Models]Provider } from './table'
import { schema } from '.'

<DatabaseProvider>
  <InterfaceProvider schema={schema} baseUrl='/api/v1'>
    <[Models]Provider>
      {children}
    </[Models]Provider>
  </InterfaceProvider>
</DatabaseProvider>

Key Points:

  • Mark table.tsx as 'use client' for Next.js
  • InterfaceProvider creates the fetch interface and provides it via context
  • useInterface() retrieves the interface from context inside TableProvider
  • Create a custom hook for easy access to table interface

Naming Conventions

  • Model name: Singular (e.g., chat, user, product)
  • Table name: Plural (e.g., chats, users, products)
  • Field components: [Field]Field (e.g., TitleField, EmailField)
  • Form components: Create[Model], Filter[Models] (e.g., CreateChat, FilterChats)
  • Provider: [Models]Provider (e.g., ChatsProvider)
  • Hook: use[Models] (e.g., useChats)

Usage Example

import { ChatsProvider } from './chat/table'
import { CreateChat, FilterChats } from './chat/forms'

function App() {
    return (
        <ChatsProvider>
            <CreateChat />
            <FilterChats />
        </ChatsProvider>
    )
}

Contributing

This is the result of years of meditation on overengineering. If you see ways to make it simpler (not more feature-rich, simpler), I'm interested.

License

MIT


Built by someone who spent 7 years learning that the hard way is usually the wrong way.

About

A framework of architectural decisions that makes the codebase easy to maintain.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published