Step-by-step guide for migrating React applications to Gea — covering project setup, component conversion, state management, routing, styling, and known pitfalls. Use when converting an existing React codebase to Gea or when advising on migration strategy.
This skill documents a battle-tested process for converting React applications to the Gea framework, based on a full migration of the oldboyxx/jira_clone — a non-trivial React app with routing, state management, styled-components, modals, and drag-and-drop.
Read reference.md in this skill directory for the complete conversion reference with side-by-side code examples.
Before starting, read the gea-framework skill (skills/gea-framework/SKILL.md) to understand Gea's core concepts: Stores, Components, JSX rules, and the Router.
Also read the gea-ui-components skill (skills/gea-ui-components/SKILL.md) — the Jira clone uses @geajs/ui for Dialog, Button, Select, Avatar, Toaster, and Link. Most React apps have custom or third-party versions of these; switching to @geajs/ui eliminates significant migration work.
jira_clone_gea/).package.json, vite.config.ts, index.html, and src/main.ts.@geajs/core, @geajs/vite-plugin, and @geajs/ui.Convert stores and API utilities before touching any UI. This gives you a working data layer to test components against.
useState/useMergeState hooks) into a Gea Store class.@geajs/ui's ToastStore so call sites use a familiar toastStore.success(msg) / toastStore.error(err) API.useEffect into App.created().Start with the root App component and work down the component tree:
created() for initializationtemplate()@geajs/ui Dialog@geajs/ui Select, buttons with @geajs/ui Button@geajs/ui equivalentsConvert styled-components (or CSS-in-JS) to plain CSS. Use CSS variables for design tokens.
Replace react-router-dom with Gea's built-in router. Use matchRoute for URL-driven modals.
Compare both apps side-by-side, pixel by pixel. Fix visual discrepancies by inspecting the React app's computed styles and replicating exact values.
| React | Gea |
|---|---|
function MyComponent() {} with hooks | class MyComponent extends Component {} with member variables |
function MyComponent({ props }) (stateless) | export default function MyComponent({ props }) |
useState(initial) | Member variable: myField = initial |
useEffect(() => {}, []) | created() lifecycle method |
useEffect(() => { return cleanup }, []) | created() + dispose() |
useRef() for DOM | ref={this.myElement} on the element, or this.el for the root |
useCallback / useMemo | Not needed — use class methods or store getters |
React.Fragment / <>...</> | Not supported — use a wrapper <div> |
className="foo" | class="foo" |
style={{ color: 'red' }} | style={{ color: 'red' }} (same syntax — compiled to CSS string) |
onClick={fn} | click={fn} |
onChange={fn} (on input) | input={fn} (for text) or change={fn} (for checkbox/select) |
onKeyDown={fn} | keydown={fn} |
<div {...props} /> | Not supported — destructure and pass props individually (compile error) |
dangerouslySetInnerHTML={{ __html: html }} | Use onAfterRender with el.innerHTML |
children | children prop (works the same) |
Render props renderContent={modal => <Foo />} | Supported — render props compile to component instantiation |
{(data) => <Child />} (function as child) | Not supported — use named render prop attributes instead (compile error) |
propTypes / defaultProps | Not needed — use TypeScript types and default parameter values |
React:
import ReactDOM from 'react-dom'
ReactDOM.render(<App />, document.getElementById('root'))
Gea:
import App from './App'
import './styles.css'
const app = new App()
app.render(document.getElementById('app'))
React (hooks + context):
const [filters, setFilters] = useState({ searchTerm: '', userIds: [] })
const updateFilter = (key, value) => setFilters(prev => ({ ...prev, [key]: value }))
Gea (Store):
class FiltersStore extends Store {
searchTerm = ''
userIds: string[] = []
setSearchTerm(val: string) { this.searchTerm = val }
toggleUserId(id: string) {
const idx = this.userIds.indexOf(id)
if (idx >= 0) this.userIds.splice(idx, 1)
else this.userIds.push(id)
}
}
export default new FiltersStore()
Key differences:
this.count++, this.items.push(x), this.items.splice(i, 1). No spread operators or immutable patterns needed.useMemo with class getters.push, pop, splice, sort, reverse, shift, unshift all trigger reactivity.React (react-router-dom v5):
import { useHistory, useRouteMatch, Route, Switch } from 'react-router-dom'
const history = useHistory()
history.push('/board')
<Route path="/board" component={Board} />
<Route path="/settings" component={Settings} />
Gea:
// src/router.ts — bare Router, no view imports (avoids circular dependencies)
import { Router } from '@geajs/core'
export const router = new Router()
// App.tsx — set routes here where both router and views are available
import { router } from './router'
import Project from './views/Project'
import Board from './views/Board'
router.setRoutes({
'/': '/project/board',
'/project': {
layout: Project,
guard: AuthGuard,
children: {
'/board': Board,
'/settings': ProjectSettings,
},
},
})
// App template — render the router's resolved page
template() {
return (
<div class="app">
<Outlet />
</div>
)
}
Key differences:
router.ts exports a bare Router instance — no view imports, avoiding circular dependencies (views import router, so router.ts must not import views).router.setRoutes(...) is called in App.tsx where both the router and view components are available.router.push(path) for navigation, router.replace(path) for replacing history.router.path and router.params are reactive — read them in template() or class getters.<Outlet /> to render the resolved page/layout hierarchy.page prop.React uses route-level components or HOCs for auth protection:
<Route path="/dashboard" render={() =>
isAuthenticated ? <Dashboard /> : <Redirect to="/login" />
} />
Gea uses guards — synchronous functions on route groups:
import authStore from './stores/auth-store'
import PageLoader from './components/PageLoader'
export const AuthGuard = () => {
if (authStore.isAuthenticated) return true
return PageLoader // show this component instead
// or: return '/login' — redirect to login page
}
Apply a guard to a route group to protect all children:
'/project': {
layout: Project,
guard: AuthGuard,
children: {
'/board': Board,
'/settings': ProjectSettings,
},
}
Guard return values:
| Return | Effect |
|---|---|
true | Proceed to the route |
string | Redirect to that path |
Component | Render it instead of the route |
Guards on nested groups stack parent → child. The parent guard runs first; the child guard only runs if the parent passes.
Guards are intentionally synchronous — they check store state, not async APIs. For async checks (API calls, fetching data), use created() in the component.
React apps commonly use styled-components or CSS-in-JS. Gea uses plain CSS with class attributes (optionally with Tailwind).
Conversion process:
Styles.js).<div class="my-class">.class={`btn ${active ? 'active' : ''}`}style — either a string (style={`width:${size}px`}) or a style object (style={{ width: size + 'px' }}). Gea supports React-style camelCase style objects.| React | Gea | Notes |
|---|---|---|
onClick={fn} | click={fn} | Both native and React-style names work |
onChange={fn} on <input type="text"> | input={fn} | input fires on every keystroke; change fires on blur |
onChange={fn} on <select> | change={fn} | |
onChange={fn} on <input type="checkbox"> | change={fn} | Use with checked={bool} |
onBlur={fn} | blur={fn} | |
onFocus={fn} | focus={fn} | |
onKeyDown={fn} | keydown={fn} | |
onSubmit={fn} | submit={fn} | |
onDoubleClick={fn} | dblclick={fn} | |
onDragStart={fn} | dragstart={fn} | Native HTML5 drag-and-drop |
onDragEnd={fn} | dragend={fn} | |
onDragOver={fn} | dragover={fn} | |
onDragLeave={fn} | dragleave={fn} | |
onDrop={fn} | drop={fn} |
| React Hook | Gea Equivalent |
|---|---|
useState | Member variable (this.myField = value) |
useEffect(fn, []) | created() lifecycle |
useEffect(fn, [dep]) | Read dep in template() — compiler creates observer automatically |
useEffect(() => () => cleanup) | dispose() lifecycle (call super.dispose() if overriding) |
useRef | ref={this.myEl} for specific elements; this.el for root; member variable for mutable refs |
useMemo(fn, [deps]) | Store getter or class getter |
useCallback(fn, [deps]) | Class method (stable by default) |
useContext | Import the store singleton directly |
useReducer | Store with methods |
Custom hooks (e.g. useMergeState) | Store class or component methods |
| React Library | Gea Replacement |
|---|---|
react-router-dom | @geajs/core router (Router, Outlet, Link, matchRoute, guards) |
styled-components / CSS-in-JS | Plain CSS + class attributes + inline style for dynamic values |
react-beautiful-dnd | Manual drag-and-drop with native HTML5 drag events (dragstart, dragend, dragover, drop) |
react-modal / custom modals | @geajs/ui Dialog component |
Custom <Select> / react-select | @geajs/ui Select component |
Custom <Button> | @geajs/ui Button component |
react-toastify / custom toasts | @geajs/ui Toaster + ToastStore |
Custom <Avatar> | @geajs/ui Avatar component |
prop-types | TypeScript types |
lodash/xor (for toggle arrays) | Array.indexOf + splice / filter in store methods |
moment / date-fns | Custom utility functions or native Intl.DateTimeFormat |
react-quill / rich text editors | Integrate via onAfterRender + manual DOM management |
React typically uses a route-level Authenticate component with useEffect:
const Authenticate = () => {
const history = useHistory()
useEffect(() => {
if (!getStoredAuthToken()) {
api.post('/authentication/guest').then(({ authToken }) => {
storeAuthToken(authToken)
history.push('/')
})
}
}, [])
return <PageLoader />
}
Gea uses a route guard + router.setRoutes in App:
// router.ts — bare Router instance (no view imports to avoid circular deps)
import { Router } from '@geajs/core'
export const router = new Router()
// App.tsx — guard + route config + async init
import { router } from './router'
import authStore from './stores/auth-store'
import projectStore from './stores/project-store'
import PageLoader from './components/PageLoader'
import Project from './views/Project'
import Board from './views/Board'
import ProjectSettings from './views/ProjectSettings'
const AuthGuard = () => {
if (authStore.isAuthenticated && !projectStore.isLoading) return true
return PageLoader
}
router.setRoutes({
'/': '/project/board',
'/project': {
layout: Project,
guard: AuthGuard,
children: {
'/board': Board,
'/settings': ProjectSettings,
},
},
})
export default class App extends Component {
async created() {
if (!authStore.isAuthenticated) {
await authStore.authenticate()
} else {
await authStore.fetchCurrentUser()
}
await projectStore.fetchProject()
router.replace(router.path)
}
template() {
return (
<div class="app">
<Outlet />
<Toaster />
</div>
)
}
}
The guard shows PageLoader until auth and data loading complete. App.created() handles the async work, then router.replace(router.path) re-triggers route resolution so the guard re-evaluates and passes. Routes are set in App.tsx (not router.ts) to avoid circular dependencies — views import router, so router.ts must not import views.
@geajs/ui DialogReact typically uses a Modal component with render props and portal:
<Route path="/issues/:id" render={props => (
<Modal isOpen onClose={() => history.push('/board')}
renderContent={modal => <IssueDetails issueId={props.match.params.id} />}
/>
)} />
Gea uses @geajs/ui Dialog with open and onOpenChange props:
import { Dialog } from '@geajs/ui'
// Route-driven dialog (opens based on URL)
{this.showIssueDetail && (
<Dialog
open={true}
onOpenChange={(d: any) => {
if (!d.open) this.closeIssueDetail()
}}
class="dialog-issue-detail"
>
<IssueDetails issueId={this.issueId} onClose={() => this.closeIssueDetail()} />
</Dialog>
)}
// State-driven dialog (opens based on component state)
{this.searchModalOpen && (
<Dialog
open={true}
onOpenChange={(d: any) => {
if (!d.open) this.closeSearchModal()
}}
class="dialog-search"
>
<IssueSearch onClose={() => this.closeSearchModal()} />
</Dialog>
)}
Key Dialog patterns:
open={true} and conditionally render the Dialog.onOpenChange: Listen for {open: false} to trigger cleanup.router.params to derive open state from the URL. Close by navigating away.this.searchModalOpen) to toggle.page PropReact uses <Route> components and useRouteMatch for view switching. Gea uses layouts in the route config — the router resolves the child component and passes it as a page prop:
// Project is a layout — route config passes the resolved child as `page`
export default class Project extends Component {
get issueId(): string {
return router.params.issueId || ''
}
get showIssueDetail(): boolean {
return !!this.issueId
}
template({ page }: any) {
return (
<div class="project-page">
<Sidebar />
<div class="page-content">
{page}
</div>
{this.showIssueDetail && (
<Dialog open={true} onOpenChange={...}>
<IssueDetails issueId={this.issueId} />
</Dialog>
)}
</div>
)
}
}
The route config determines which child page resolves to:
'/project': {
layout: Project,
children: {
'/board': Board,
'/board/issues/:issueId': Board,
'/settings': ProjectSettings,
},
}
{page} renders the resolved child — no manual router.path checks needed for view switching.issueId are available on router.params for overlay logic (dialogs, modals).React typically uses useState for errors plus ad-hoc validation or a library like Formik/Yup.
Gea uses member variables for form state and a lightweight validator utility:
// utils/validation.ts
type Validator = (value: any) => string | false
export const is = {
required: (): Validator => value =>
(value === undefined || value === null || value === '') && 'This field is required',
maxLength: (max: number): Validator => value =>
!!value && value.length > max && `Must be at most ${max} characters`,
url: (): Validator => value =>
!!value && !/^https?:\/\//.test(value) && 'Must be a valid URL',
}
export function generateErrors(
fieldValues: Record<string, any>,
fieldValidators: Record<string, Validator | Validator[]>,
): Record<string, string> {
const errors: Record<string, string> = {}
for (const [name, validators] of Object.entries(fieldValidators)) {
const list = Array.isArray(validators) ? validators : [validators]
for (const validator of list) {
const msg = validator(fieldValues[name])
if (msg && !errors[name]) errors[name] = msg
}
}
return errors
}
// Component usage
export default class IssueCreate extends Component {
title = ''
errors: Record<string, string> = {}
async handleSubmit() {
this.errors = generateErrors(
{ title: this.title },
{ title: [is.required(), is.maxLength(200)] },
)
if (Object.keys(this.errors).length > 0) return
// proceed with API call
}
template() {
return (
<div>
<input
class={`input ${this.errors.title ? 'input-error' : ''}`}
value={this.title}
input={(e: any) => { this.title = e.target.value }}
/>
{this.errors.title && <div class="form-error">{this.errors.title}</div>}
</div>
)
}
}
@geajs/ui Select in FormsReact uses custom select components or react-select. Gea uses @geajs/ui Select:
import { Select } from '@geajs/ui'
// Single select — value is always an array, wrap/unwrap manually
<Select
class="w-full"
items={typeOptions}
value={[this.type]}
onValueChange={(d: { value: string[] }) => {
const v = d.value[0]
if (v !== undefined) this.type = v
}}
placeholder="Type"
/>
// Multi-select
<Select
class="w-full"
multiple={true}
items={userOptions}
value={this.userIds}
onValueChange={(d: { value: string[] }) => {
this.userIds = d.value
}}
placeholder="Assignees"
/>
Key differences:
items is an array of { value, label } objects.value is always an array — for single select, wrap in [val] and extract d.value[0].onValueChange receives { value: string[] }, not the raw value.class="w-full" for full-width selects.React uses react-toastify or custom toast components. Gea uses @geajs/ui:
// stores/toast-store.ts
import { ToastStore } from '@geajs/ui'
const toastStore = {
success(title: string) {
ToastStore.success({ title })
},
error(err: unknown) {
ToastStore.error({
title: 'Error',
description: typeof err === 'string' ? err : (err as Error)?.message || String(err),
})
},
}
export default toastStore
Add <Toaster /> to the App template:
import { Toaster } from '@geajs/ui'
template() {
return (
<div class="app">
<Project />
<Toaster />
</div>
)
}
React apps commonly use react-beautiful-dnd. Gea implements DnD with native events:
Draggable card:
export default class IssueCard extends Component {
_didDrag = false
handleClick() {
if (this._didDrag) return
router.push(`/project/board/issues/${this.props.issueId}`)
}
onDragStart(e: DragEvent) {
this._didDrag = true
e.dataTransfer?.setData('text/plain', this.props.issueId)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
;(e.currentTarget as HTMLElement).classList.add('dragging')
}
onDragEnd(e: DragEvent) {
;(e.currentTarget as HTMLElement).classList.remove('dragging')
queueMicrotask(() => { this._didDrag = false })
}
template({ issueId, title }: any) {
return (
<div
class="issue-card"
draggable={true}
dragstart={(e: DragEvent) => this.onDragStart(e)}
dragend={(e: DragEvent) => this.onDragEnd(e)}
click={() => this.handleClick()}
>
{title}
</div>
)
}
}
Drop target column:
<div
class="board-list-issues"
dragover={(e: DragEvent) => {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
;(e.currentTarget as HTMLElement).classList.add('board-list--drag-over')
}}
dragleave={(e: DragEvent) => {
const el = e.currentTarget as HTMLElement
const related = e.relatedTarget as Node | null
if (!related || !el.contains(related)) el.classList.remove('board-list--drag-over')
}}
drop={(e: DragEvent) => {
e.preventDefault()
;(e.currentTarget as HTMLElement).classList.remove('board-list--drag-over')
const id = e.dataTransfer?.getData('text/plain')
if (id) projectStore.moveIssueToColumn(id, status)
}}
>
{issues.map(issue => <IssueCard key={issue.id} ... />)}
</div>
Key patterns:
_didDrag flag: Prevents click from firing after a drag. Reset via queueMicrotask to let the click event fire and be ignored first.dragleave child bubbling: Check e.relatedTarget to avoid false drag-leave when moving over child elements.dragging and board-list--drag-over directly on the element.React uses useEffect with a debounce timeout or a library. Gea uses a member variable timer:
export default class IssueSearch extends Component {
searchTerm = ''
matchingIssues: any[] = []
isLoading = false
_debounceTimer: any = null
handleInput(e: any) {
this.searchTerm = e.target.value
clearTimeout(this._debounceTimer)
if (this.searchTerm.trim()) {
this._debounceTimer = setTimeout(() => this.doSearch(), 300)
} else {
this.matchingIssues = []
}
}
async doSearch() {
this.isLoading = true
try {
const data = await api.get('/issues', { searchTerm: this.searchTerm.trim() })
this.matchingIssues = data || []
} catch {
this.matchingIssues = []
} finally {
this.isLoading = false
}
}
template({ onClose }) {
return (
<div class="issue-search">
<input
type="text"
autofocus
placeholder="Search issues..."
value={this.searchTerm}
input={(e: any) => this.handleInput(e)}
/>
{this.isLoading && <Spinner size={20} />}
{this.matchingIssues.map(issue => (
<Link key={issue.id} to={`/project/board/issues/${issue.id}`} onNavigate={() => onClose?.()}>
{issue.title}
</Link>
))}
</div>
)
}
}
Key patterns:
setTimeout / clearTimeout: Store the timer as a member variable, clear on each keystroke.Link with onNavigate: Close the search modal when a result is clicked using the onNavigate callback.matchingIssues to [] when the search term is cleared.React uses useEffect to attach keydown listeners. Gea uses created() + dispose():
export default class CommentCreate extends Component {
isFormOpen = false
private _onKey: ((e: KeyboardEvent) => void) | null = null
created() {
this._onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return
if (e.key === 'm' || e.key === 'M') {
e.preventDefault()
this.openForm()
}
}
document.addEventListener('keydown', this._onKey)
}
dispose() {
if (this._onKey) document.removeEventListener('keydown', this._onKey)
super.dispose()
}
}
Always call super.dispose() when overriding dispose() in a class component.
Update local state immediately, send API request, revert on failure:
async updateIssue(fields: any): Promise<void> {
if (!this.issue) return
const currentFields = { ...this.issue }
Object.assign(this.issue, fields)
projectStore.updateLocalProjectIssues(this.issue.id, fields)
try {
await api.put(`/issues/${this.issue.id}`, fields)
} catch {
Object.assign(this.issue, currentFields)
projectStore.updateLocalProjectIssues(this.issue.id, currentFields)
}
}
React uses useState for editing state. Gea uses member variables:
export default class IssueDetails extends Component {
isEditingTitle = false
editTitle = ''
startEditTitle() {
this.editTitle = issueStore.issue?.title || ''
this.isEditingTitle = true
}
saveTitle() {
this.isEditingTitle = false
if (this.editTitle.trim() && this.editTitle !== issueStore.issue?.title) {
issueStore.updateIssue({ title: this.editTitle.trim() })
}
}
template() {
return (
<div>
{!this.isEditingTitle && (
<h2 click={() => this.startEditTitle()}>{issueStore.issue?.title}</h2>
)}
{this.isEditingTitle && (
<textarea
value={this.editTitle}
input={(e: any) => { this.editTitle = e.target.value }}
blur={() => this.saveTitle()}
keydown={(e: any) => {
if (e.key === 'Enter') { e.preventDefault(); this.saveTitle() }
}}
></textarea>
)}
</div>
)
}
}
Instead of useOnOutsideClick with mousedown listeners, use an overlay <div>:
export default class IssueDetails extends Component {
openDropdown: string | null = null
toggleDropdown(name: string) {
this.openDropdown = this.openDropdown === name ? null : name
}
closeDropdown() {
this.openDropdown = null
}
template() {
return (
<div class="issue-details-right">
{this.openDropdown && <div class="dropdown-overlay" click={() => this.closeDropdown()}></div>}
<div class="field field--relative">
<button click={() => this.toggleDropdown('status')}>Status</button>
{this.openDropdown === 'status' && (
<div class="custom-dropdown">
{statusOptions.map(opt => (
<div key={opt.value} click={() => {
issueStore.updateIssue({ status: opt.value })
this.closeDropdown()
}}>
{opt.label}
</div>
))}
</div>
)}
</div>
</div>
)
}
}
This avoids global event listeners. The overlay is a transparent full-screen <div> positioned behind the dropdown that catches clicks.
When a component reads from a store that holds per-view data (e.g. a single issue), clear the store when leaving:
closeIssueDetail() {
issueStore.clear()
router.push('/project/board')
}
// In the store
clear(): void {
this.issue = null
this.isLoading = false
}
@geajs/ui ComponentsMany React apps have custom implementations of common UI elements. Replace them with @geajs/ui:
| Custom React Component | @geajs/ui Replacement | Key Props |
|---|---|---|
<Modal> | <Dialog> | open, onOpenChange, title, description |
<Button> | <Button> | variant ("default", "destructive", "ghost"), disabled, click |
<Select> | <Select> | items, value (array), onValueChange, multiple, placeholder |
<Avatar> | <Avatar> | src, name, class |
<Toast> / notifications | <Toaster> + ToastStore | Place <Toaster /> in App root |
<Link> | <Link> (from @geajs/core) | to, class, onNavigate |
import { Dialog, Button } from '@geajs/ui'
export default class ConfirmModal extends Component {
isOpen = false
open() { this.isOpen = true }
close() { this.isOpen = false }
handleConfirm() {
this.props.onConfirm?.()
this.close()
}
template({ title = 'Confirm', message = 'Are you sure?', confirmText = 'Confirm' }) {
return (
<Dialog
open={this.isOpen}
onOpenChange={(d: { open: boolean }) => { if (!d.open) this.close() }}
title={title}
description={message}
>
<div class="flex gap-2 justify-end mt-4">
<Button variant="default" click={() => this.handleConfirm()}>{confirmText}</Button>
<Button variant="ghost" click={() => this.close()}>Cancel</Button>
</div>
</Dialog>
)
}
}
Use this checklist when converting a React app to Gea:
package.json, vite.config.ts, index.html, main.ts@geajs/core, @geajs/vite-plugin, @geajs/ui:rootlocalStorage helpers (usually copy as-is)is.required(), is.maxLength(), generateErrors()@geajs/ui ToastStoreRouter in router.ts, setRoutes with guards/layouts/redirects in App.tsxcreated() for async auth + data fetching, <Outlet /> + <Toaster /> in templatepage prop from router@geajs/ui Dialog with open + onOpenChange@geajs/ui Select for dropdowns, @geajs/ui Button for actions@geajs/ui Avatar; custom Icon, Spinner as function componentsonClick → click, onChange → input/changeclass attributes + inline style for computed values_didDrag flag for click suppressioncreated() + dispose() with super.dispose()setTimeout / clearTimeout in member variablesclear() methods for per-view stores when navigating away