Most of what gets written about AI coding standards stays at the level of principle. Define your architecture. Be explicit about naming. Decide where state lives. All reasonable advice, and all of it fairly abstract until you sit down and actually look at two versions of the same file.
So here is the concrete version. One component, generated by the AI without any project rules in place, then the same component built again with three specific rules provided beforehand. Same feature, same requirements, same model. The only variable is whether the rules existed before generation started.
The feature is a user list with search and a status filter. Nothing exotic. The kind of thing that shows up in almost every internal tool.
The component without rules
This is roughly what came back the first time, with a prompt that described the feature and nothing else.
function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [status, setStatus] = useState('all')
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data.users.map(u => ({
id: u.user_id,
name: u.full_name,
email: u.email_address,
status: u.account_status
})))
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
const filtered = users.filter(u => {
const matchesSearch = u.name.toLowerCase().includes(search.toLowerCase())
const matchesStatus = status === 'all' || u.status === status
return matchesSearch && matchesStatus
})
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<select value={status} onChange={e => setStatus(e.target.value)}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
{filtered.map((u, i) => (
<div key={i}>
<span>{u.name}</span>
<span>{u.email}</span>
<span>{u.status}</span>
</div>
))}
</div>
)
}
This code works. If you drop it into a project it renders, it filters, it handles the loading and error cases. Nothing here would throw an exception in normal use.
It also does four different jobs in one file. It fetches data, it transforms the API response into a different shape, it manages filter state and computes the filtered result, and it renders the UI. The API field names get mapped inline inside the fetch callback, which means the component knows the exact shape the backend returns. The list uses the array index as a key, which will cause React to misidentify rows the moment filtering changes the order. The loading state is bare text.
None of these are things the AI does not know about. Ask it directly whether index as key is a good idea and it will explain why it is not. The knowledge is there. What is missing is anything forcing that knowledge to apply during generation.
The three rules
These are the rules that went in before the second attempt. Deliberately short, because a first rule set that takes an afternoon to write never gets written.
1. Components render only. Data fetching lives in a service function.
State logic lives in a custom hook. A component that fetches,
transforms, and renders is three things pretending to be one.
2. API responses get mapped to domain objects in the service layer,
before the data reaches any hook or component. Nothing above the
service layer knows what the backend field names are.
3. Every list item key is a stable identifier from the data itself.
If the data has no stable id, generate one during the service
layer mapping, never inline in the render.
Three rules. No architecture document, no folder structure mandate, nothing about naming conventions yet. Just the three decisions that address what actually went wrong in the first version.
The same component with the rules in place
The AI split the work into three files without being told to split it into three files. That happened because rule one made the boundaries explicit.
First, the service:
// services/userService.js
export async function fetchUsers() {
const res = await fetch('/api/users')
if (!res.ok) {
throw new Error(`Failed to load users: ${res.status}`)
}
const data = await res.json()
return data.users.map(toUser)
}
function toUser(raw) {
return {
id: raw.user_id,
name: raw.full_name,
email: raw.email_address,
status: raw.account_status
}
}
The mapping moved here, which is rule two doing its job. The backend field names now appear in exactly one place in the codebase. If the API renames full_name to display_name next quarter, one function changes and nothing else in the application notices.
Then the hook:
// hooks/useUserList.js
export function useUserList() {
const [users, setUsers] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
useEffect(() => {
let cancelled = false
fetchUsers()
.then(result => {
if (!cancelled) {
setUsers(result)
setIsLoading(false)
}
})
.catch(err => {
if (!cancelled) {
setError(err.message)
setIsLoading(false)
}
})
return () => { cancelled = true }
}, [])
const visibleUsers = useMemo(() => {
const term = searchTerm.toLowerCase()
return users.filter(user => {
const matchesSearch = user.name.toLowerCase().includes(term)
const matchesStatus =
statusFilter === 'all' || user.status === statusFilter
return matchesSearch && matchesStatus
})
}, [users, searchTerm, statusFilter])
return {
visibleUsers,
isLoading,
error,
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter
}
}
Something worth noticing here that no rule asked for. The cleanup flag preventing a state update after unmount appeared in this version and not in the first one. So did the useMemo around the filter computation, and so did the isLoading naming with the boolean prefix.
I did not write rules for any of those three things. My read is that once the hook exists as its own file with a single clear job, the generation is no longer juggling four concerns at once, and the details that get dropped under that load stop getting dropped. Whether that generalizes or whether I got lucky in this particular case, I honestly do not know. It happened consistently enough across the components I rebuilt that I stopped treating it as a coincidence.
And the component:
// components/UserList.jsx
export function UserList() {
const {
visibleUsers,
isLoading,
error,
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter
} = useUserList()
if (isLoading) return <UserListSkeleton />
if (error) return <ErrorMessage message={error} />
return (
<div>
<input
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
aria-label="Search users"
/>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
aria-label="Filter by status"
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<ul>
{visibleUsers.map(user => (
<li key={user.id}>
<span>{user.name}</span>
<span>{user.email}</span>
<span>{user.status}</span>
</li>
))}
</ul>
</div>
)
}
The component is now twenty lines of rendering. user.id as the key came directly from rule three. The ul and li elements, the aria labels on the inputs, the skeleton instead of the text placeholder, none of that was in the rules either.
What the comparison actually tells you
The first version was not badly written. It was written without a defined boundary, and everything else followed from that.
When a single function is responsible for fetching, mapping, filtering, and rendering, the generation is making all of those decisions in one pass. The API mapping goes inline because that is where the fetch happens to be. The key goes to the index because the render is the fourth thing in a chain of concerns and the identifier question does not get much attention by the time it comes up. The loading state is text because it is a detail inside a component that already has too many jobs.
Three rules changed what the code looks like far more than three rules should be able to. That is what makes this worth actually running yourself rather than taking my word for it, because the multiplier effect is the part that does not come across in the abstract.
Try it on something you already have
Take a component in your project that the AI generated in a single session. Copy the three rules above, paste them in front of a prompt describing the same feature, and generate it again from scratch.
The second version will not be perfect. Mine was not either. But the comparison tells you something specific and immediately actionable, which is which of your recurring corrections are actually the AI missing knowledge, and which ones are just the absence of a boundary that would have made the correct choice obvious. In my experience it is almost entirely the second category, and that category is the one you can fix in an afternoon.
Top comments (0)