<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Andrei Kornev</title>
    <description>The latest articles on DEV Community by Andrei Kornev (@andrei-kornev-frontend).</description>
    <link>https://dev.to/andrei-kornev-frontend</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4197888%2Fbe53f6af-33fa-4339-bb83-b43018884c6d.jpg</url>
      <title>DEV Community: Andrei Kornev</title>
      <link>https://dev.to/andrei-kornev-frontend</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andrei-kornev-frontend"/>
    <language>en</language>
    <item>
      <title>Your Redux Store Is Not a Cache</title>
      <dc:creator>Andrei Kornev</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:03:22 +0000</pubDate>
      <link>https://dev.to/andrei-kornev-frontend/your-redux-store-is-not-a-cache-3kmg</link>
      <guid>https://dev.to/andrei-kornev-frontend/your-redux-store-is-not-a-cache-3kmg</guid>
      <description>&lt;p&gt;At work I inherited an internal resource planner: about 500 people used it to plan who works on what. I was the only frontend developer on it, and the codebase was old.&lt;/p&gt;

&lt;p&gt;Every piece of data in that app lived in Redux Toolkit. Employees, projects, weekly allocations, departments: each had a slice, a set of thunks, and hand-written &lt;code&gt;isLoading&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; flags. It worked, technically. It also refetched everything all the time, showed loaders in the wrong places, and sometimes showed two screens that disagreed with each other.&lt;/p&gt;

&lt;p&gt;The fix wasn't a better Redux setup. It was realizing that most of what we kept in Redux wasn't app state at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it looked like
&lt;/h3&gt;

&lt;p&gt;A typical slice, simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchEmployees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAsyncThunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;employees/fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/departments/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/employees`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;employeesSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSlice&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;employees&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;reducers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="na"&gt;extraReducers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;builder&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchEmployees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchEmployees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfilled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchEmployees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And every component that needed the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetchEmployees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply that by every entity in the app. A lot of code, and all of it was solving the same problem badly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four symptoms of one problem
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. The same data, fetched again and again.&lt;/strong&gt; Page one showed data A and B. Page two showed B and C. Open page two right after page one, and B was requested again, even though we got it a second ago. Each page's &lt;code&gt;useEffect&lt;/code&gt; had no idea the data was already there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Every tab switch was a new request.&lt;/strong&gt; Switching tabs unmounted one component and mounted another. Mount meant &lt;code&gt;useEffect&lt;/code&gt;, and &lt;code&gt;useEffect&lt;/code&gt; meant a request. Go back and forth three times, get six requests and six spinners.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Loaders that lied.&lt;/strong&gt; One &lt;code&gt;isLoading&lt;/code&gt; flag per slice, shared by every request to that slice. Two requests in flight: the first one finishes, sets &lt;code&gt;isLoading = false&lt;/code&gt;, and the loader disappears while the second is still loading. Or the opposite: a background refresh flips the flag and replaces content that was already on screen with a spinner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Screens that disagreed.&lt;/strong&gt; After someone changed an allocation, the page they were on updated. The summary page didn't, because it had its own copy of that data in another slice, and nothing told it to refresh. You only saw the right numbers after a reload.&lt;/p&gt;

&lt;p&gt;These look like four bugs. They're one: we were building a cache by hand, without any of the things a cache needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two kinds of state
&lt;/h3&gt;

&lt;p&gt;The data in that app came in two very different kinds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Server state&lt;/th&gt;
&lt;th&gt;Client state&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Examples&lt;/td&gt;
&lt;td&gt;employees, projects, allocations&lt;/td&gt;
&lt;td&gt;selected week, open modal, filters, active tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who owns it&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;td&gt;the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can it go stale?&lt;/td&gt;
&lt;td&gt;yes, someone else can change it&lt;/td&gt;
&lt;td&gt;no, only this user changes it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it needs&lt;/td&gt;
&lt;td&gt;caching, dedup, refetching, invalidation&lt;/td&gt;
&lt;td&gt;a place to live and a way to update it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Server state is a &lt;strong&gt;copy&lt;/strong&gt; of data that lives somewhere else. The questions you have to answer for it are hard: how fresh is this copy, who else is asking for the same thing right now, what do we refetch after a change. Client state is the opposite: synchronous, owned by you, never stale.&lt;/p&gt;

&lt;p&gt;Redux is a good tool for the second kind. For the first kind, putting server data in Redux means writing caching, request deduplication and invalidation yourself, in every slice. We hadn't. Hence the four symptoms.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: one tool for each kind
&lt;/h3&gt;

&lt;p&gt;I moved server state to TanStack Query and the little client state that was left to Zustand.&lt;/p&gt;

&lt;p&gt;The same employees data with TanStack Query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;employeeKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;employees&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kd"&gt;as const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;byDepartment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;employees&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kd"&gt;as const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useEmployees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;employeeKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byDepartment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/departments/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/employees`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. No slice, no thunk, no flags. And each symptom went away for a specific reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate requests.&lt;/strong&gt; The query key identifies the data. Any component on any page that asks for &lt;code&gt;['employees', 'design']&lt;/code&gt; gets the cached result. If two components ask at the same moment, TanStack Query sends one request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tab switches.&lt;/strong&gt; &lt;code&gt;staleTime&lt;/code&gt; says how long the data counts as fresh. Inside that window, switching tabs shows the cached data instantly, with no request at all. After it, the cached data shows instantly and refreshes in the background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lying loaders.&lt;/strong&gt; Every query has its own status, so one request can't hide another one's loader. And TanStack Query separates two states I'd been mixing up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFetching&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useEmployees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TableSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt; &lt;span class="c1"&gt;// first load, nothing to show yet&lt;/span&gt;

&lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isFetching&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RefreshIndicator&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* background refresh, keep the table */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmployeesTable&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A skeleton only when there's nothing to show. A small indicator when we're refreshing data the user can already see.&lt;/p&gt;

&lt;p&gt;On screens with a period switcher, &lt;code&gt;placeholderData: keepPreviousData&lt;/code&gt; keeps the current period on screen while the next one loads, instead of flashing an empty table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screens that disagree.&lt;/strong&gt; There's one copy of each piece of data now, so there's nothing to get out of sync. After a change, the mutation tells the cache what's outdated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useUpdateAllocation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useMutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;allocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Allocation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/allocations/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;allocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allocation&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;// Returning the promise keeps the mutation pending until fresh data is in.&lt;/span&gt;
    &lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidateQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allocations&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidateQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning the promise matters: the mutation stays pending until the fresh data arrives, so the Save button stays disabled and the old numbers never flash back.&lt;br&gt;
Every screen showing allocations or the summary refetches. The summary page is right without a reload.&lt;/p&gt;
&lt;h3&gt;
  
  
  What was left for Zustand
&lt;/h3&gt;

&lt;p&gt;Once the server data moved out, the "global store" turned out to be tiny: the selected week, the active department tab, a few filters, which modal is open.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PlannerUiState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;week&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;setWeek&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;week&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;setDepartment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usePlannerUi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PlannerUiState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()((&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;week&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;currentWeek&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;setWeek&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;week&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;week&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;setDepartment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;departmentId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two layers meet in the component: client state picks &lt;em&gt;what&lt;/em&gt; to show, server state fetches it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;departmentId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePlannerUi&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useEmployees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;departmentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  "Why not RTK Query?"
&lt;/h3&gt;

&lt;p&gt;Fair question. RTK Query solves the same problem in the same way: cached queries, tags for invalidation, per-request status. If your app is already deep in Redux, moving server data to RTK Query is probably the cheaper step, and it would have fixed all four symptoms too.&lt;/p&gt;

&lt;p&gt;I picked TanStack Query and Zustand because I find them simpler to read and write: less ceremony per query, and a client store that's a plain hook. That's taste. The part that isn't taste is the split. Whatever you use, don't keep server data in the same place and the same way as UI state.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I migrated without stopping feature work
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add TanStack Query next to Redux.&lt;/strong&gt; Wrap the app in &lt;code&gt;QueryClientProvider&lt;/code&gt; and change nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the most shared read data first.&lt;/strong&gt; Data that several pages show gives the biggest win, like employees or departments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One entity at a time.&lt;/strong&gt; Write the query hook, switch components to it, delete the thunk and the slice. Ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move mutations together with their queries,&lt;/strong&gt; so invalidation covers every screen that shows the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move what's left to Zustand at the end.&lt;/strong&gt; By then it's a handful of fields.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two mistakes to avoid on the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't copy query data into a store.&lt;/strong&gt; &lt;code&gt;useEffect(() =&amp;gt; setEmployees(data), [data])&lt;/code&gt; brings back the two-copies problem you just removed. Read from the query wherever you need the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put every parameter in the query key.&lt;/strong&gt; If the request depends on the week and the department, both go in the key. Otherwise two different requests share one cache entry.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The result
&lt;/h3&gt;

&lt;p&gt;No more duplicate requests. Tab switches show cached data instantly. Loaders appear only when there's really nothing on screen. And after an edit, every screen shows the same numbers without a reload. As a bonus, a big chunk of the codebase was simply deleted: thunks, &lt;code&gt;extraReducers&lt;/code&gt;, loading flags, mount effects.&lt;/p&gt;

&lt;p&gt;The biggest change wasn't the libraries. It was asking one question about every piece of state: &lt;em&gt;who owns this?&lt;/em&gt; If it's the server, it's a cache, and caching is a solved problem. Use the solution.&lt;/p&gt;

&lt;p&gt;How do you split state in your projects? Still everything in one store, or already separated?&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
    <item>
      <title>WCAG Isn't the Whole Story: Building an Accessibility Toolkit for Regional Compliance</title>
      <dc:creator>Andrei Kornev</dc:creator>
      <pubDate>Wed, 21 Oct 2026 09:10:06 +0000</pubDate>
      <link>https://dev.to/andrei-kornev-frontend/wcag-isnt-the-whole-story-building-an-accessibility-toolkit-for-regional-compliance-24b2</link>
      <guid>https://dev.to/andrei-kornev-frontend/wcag-isnt-the-whole-story-building-an-accessibility-toolkit-for-regional-compliance-24b2</guid>
      <description>&lt;p&gt;Your React app passes axe-core with zero violations. Lighthouse gives you 100 for accessibility. Then the compliance audit comes back with a list of failures.&lt;/p&gt;

&lt;p&gt;That happened to me on a commercial project where I was responsible for accessibility. The site had to meet national requirements, and the auditors weren't checking against WCAG directly. Most of the gap came from a mismatch between the tools developers use and the documents auditors read. Fixing that mismatch turned into an open-source toolkit, &lt;a href="https://github.com/biondohod/ru-a11y" rel="noopener noreferrer"&gt;ru-a11y-toolkit&lt;/a&gt;, and later into my graduation thesis.&lt;/p&gt;

&lt;p&gt;This post covers why the gap exists, how the toolkit is put together, and a few engineering decisions that might be useful even if you never touch Russian regulations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap between WCAG and the law
&lt;/h2&gt;

&lt;p&gt;Most countries don't make WCAG itself the law. They adopt a national standard based on it. The EU has EN 301 549, which matters a lot more now that the European Accessibility Act applies. The US has Section 508. Russia has GOST R 52872-2019, which is based on WCAG 2.1, and since March 2026 also Government Decree No. 102.&lt;/p&gt;

&lt;p&gt;These documents start from WCAG, but they don't simply copy it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They phrase requirements differently.&lt;/strong&gt; One WCAG criterion can turn into several separate clauses, and an auditor quotes the clause, not the success criterion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They add requirements of their own.&lt;/strong&gt; Decree 102 says pages must stay usable at 200% zoom without horizontal scrolling, page titles must describe what the page is for, and confirmations must be in the state language. Public sector sites are also expected to offer a "version for the visually impaired": a readability mode visitors can turn on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audit report is in the local language and cites local clauses.&lt;/strong&gt; A developer sees &lt;code&gt;img elements must have an alt attribute&lt;/code&gt;, and the auditor writes "violation of §5.1.1". Somebody has to translate between the two every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-jsx-a11y&lt;/code&gt; and axe-core are excellent, but they speak English and cite WCAG. On a project with regional requirements, mapping their output to the audit report becomes manual work that someone has to repeat every sprint.&lt;/p&gt;

&lt;p&gt;So I wanted tools that report every problem the way an auditor would: in Russian, with the GOST clause, the Decree 102 item and the WCAG criterion, and a concrete fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: one catalog, three checkpoints
&lt;/h2&gt;

&lt;p&gt;The toolkit is a monorepo with five packages plus an umbrella package:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;core&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rule catalog: 115 rules with texts, normative references and id aliases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ESLint plugin: 10 custom JSX rules, 26 translated jsx-a11y rules, 3 configs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;overlay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;React dev overlay: axe-core plus 44 custom DOM checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cli&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CI auditor: Puppeteer + axe-core + ESLint, console/JSON/HTML/JUnit reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;visually-impaired&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Readability mode for site visitors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two ideas shaped the design.&lt;/p&gt;

&lt;h3&gt;
  
  
  One source of truth
&lt;/h3&gt;

&lt;p&gt;Every rule title, description, fix and normative reference lives in &lt;code&gt;core&lt;/code&gt; and nowhere else. The ESLint messages, the overlay cards and the CLI reports are all built from that catalog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getRuleMeta&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ru-a11y-toolkit-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;getRuleMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image-alt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// {&lt;/span&gt;
&lt;span class="c1"&gt;//   id: 'image-alt',&lt;/span&gt;
&lt;span class="c1"&gt;//   title: 'Изображение без альтернативного текста',  // "Image without alternative text"&lt;/span&gt;
&lt;span class="c1"&gt;//   fix: 'Добавьте &amp;lt;img alt="..." /&amp;gt; или alt="" для декоративных.',&lt;/span&gt;
&lt;span class="c1"&gt;//   gost: 'ГОСТ Р 52872-2019, §5.1.1',&lt;/span&gt;
&lt;span class="c1"&gt;//   post102: 'Постановление №102, п. г)',&lt;/span&gt;
&lt;span class="c1"&gt;//   wcag: 'WCAG 2.1, 1.1.1 Non-text Content',&lt;/span&gt;
&lt;span class="c1"&gt;//   principle: 'perceivable',&lt;/span&gt;
&lt;span class="c1"&gt;//   severity: 'error',&lt;/span&gt;
&lt;span class="c1"&gt;// }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ESLint rule ids and axe-core ids are aliases of catalog entries, so &lt;code&gt;ru-a11y/require-skip-link&lt;/code&gt; and axe's &lt;code&gt;bypass&lt;/code&gt; resolve to the same rule. The same problem reads the same way in the editor, in the browser and in the CI report. When a developer pastes a CI failure into a ticket, the auditor recognizes it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shift left, then verify
&lt;/h3&gt;

&lt;p&gt;Each tool catches a different class of problems at a different stage:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ESLint&lt;/strong&gt; catches what's visible in JSX while you type: a missing skip link, a table without &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt;, a viewport that blocks zoom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The overlay&lt;/strong&gt; checks the real DOM in the browser during development: contrast, focus order, duplicate ids, anything that only exists after render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI&lt;/strong&gt; audits deployed pages in CI and fails the build when new issues show up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The packages depend only on &lt;code&gt;core&lt;/code&gt; and their own engine (ESLint, axe-core or Puppeteer). You can adopt one without the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translating jsx-a11y without forking it
&lt;/h2&gt;

&lt;p&gt;I didn't want to rewrite detection logic that &lt;code&gt;eslint-plugin-jsx-a11y&lt;/code&gt; already gets right. I only needed different messages. That turned out to be harder than expected.&lt;/p&gt;

&lt;p&gt;Normally you'd override &lt;code&gt;meta.messages&lt;/code&gt;. But jsx-a11y reports plain &lt;code&gt;message&lt;/code&gt; strings, not &lt;code&gt;messageId&lt;/code&gt;s, so there's nothing to override. And in ESLint 9 &lt;code&gt;context.report&lt;/code&gt; is frozen, so you can't monkey-patch it either.&lt;/p&gt;

&lt;p&gt;The fix is to give the original rule a different context: one that inherits everything from the real context through the prototype chain and overrides only &lt;code&gt;report&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createTranslatingContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;translatingContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;translatingContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;report&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;configurable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;descriptor&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;translatingContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wrapJsxRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalRule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mappings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;originalRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;originalRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createTranslatingContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original rule runs unchanged and never knows it's wrapped. It gets bug fixes from upstream for free, and 26 jsx-a11y rules now speak the auditor's language. The same pattern works for any plugin whose messages you need to localize or enrich, for example to add links to your internal docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules that didn't exist yet
&lt;/h2&gt;

&lt;p&gt;Some requirements have no counterpart in jsx-a11y, so I wrote 10 custom rules. &lt;code&gt;zoom-200-warning&lt;/code&gt; is a good example, because it comes straight from Decree 102. It flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a viewport meta with &lt;code&gt;user-scalable=no&lt;/code&gt; or &lt;code&gt;maximum-scale&lt;/code&gt; below 2;&lt;/li&gt;
&lt;li&gt;inline font sizes below a configurable minimum (with per-unit thresholds, since &lt;code&gt;em&lt;/code&gt; is relative to the parent);&lt;/li&gt;
&lt;li&gt;fixed inline widths over 200px, which tend to cause horizontal scrolling at 200% zoom.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Both are reported&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;meta&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, maximum-scale=1"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Note&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Page-level rules such as &lt;code&gt;require-h1&lt;/code&gt; and &lt;code&gt;require-main-landmark&lt;/code&gt; need a different trick. A linter sees files, not pages, and a &lt;code&gt;&amp;lt;Button&amp;gt;&lt;/code&gt; component shouldn't be asked for an &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;. These rules only run on page-like files: anything under &lt;code&gt;app/&lt;/code&gt;, &lt;code&gt;pages/&lt;/code&gt;, &lt;code&gt;routes/&lt;/code&gt; or &lt;code&gt;layouts/&lt;/code&gt;, or files named &lt;code&gt;page&lt;/code&gt;, &lt;code&gt;layout&lt;/code&gt;, &lt;code&gt;App&lt;/code&gt; and so on. It's a heuristic, but it matches how Next.js, Remix and most React routers organize code, and it removes almost all of the noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dev overlay that keeps up with an SPA
&lt;/h2&gt;

&lt;p&gt;The overlay renders a panel in development builds, lists violations grouped by WCAG principle and highlights the element when you click one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4bsxfgai0kg6ajqs766d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4bsxfgai0kg6ajqs766d.png" alt="Overlay preview" width="425" height="947"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few details took more time than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-scanning.&lt;/strong&gt; A &lt;code&gt;MutationObserver&lt;/code&gt; with a debounce re-runs the scan after DOM changes, so client-side navigation doesn't leave stale results. A scan that starts while another one is still running must not briefly show "no violations". That bug really shipped once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplication.&lt;/strong&gt; When axe-core and a custom check report the same rule on the same element, the user should see one card, not two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique selectors.&lt;/strong&gt; An early version merged findings on different elements that shared a tag, and clicking a card could highlight the wrong element. Every finding now gets a unique CSS selector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluding itself.&lt;/strong&gt; The overlay has buttons and text too, and it must never audit its own UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In total the overlay runs 148 checks: 104 from axe-core and 44 custom ones for things GOST and Decree 102 care about that axe doesn't cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it fail the build
&lt;/h2&gt;

&lt;p&gt;Dev tools only help when someone looks at them. The CLI is what makes accessibility a real gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx ru-a11y-cli &lt;span class="nt"&gt;--urls-file&lt;/span&gt; urls.txt &lt;span class="nt"&gt;--format&lt;/span&gt; junit &lt;span class="nt"&gt;--output&lt;/span&gt; reports &lt;span class="nt"&gt;--max-issues&lt;/span&gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It opens each URL in headless Chromium, runs axe-core, can also lint the source with &lt;code&gt;--with-eslint&lt;/code&gt;, and merges everything into one report. Exit codes are deliberate: &lt;code&gt;0&lt;/code&gt; means within the limit, &lt;code&gt;1&lt;/code&gt; means too many issues, and &lt;code&gt;2&lt;/code&gt; means the audit itself failed (a timeout or a DNS error). That way CI doesn't mistake "the site is down" for "the site is accessible".&lt;/p&gt;

&lt;p&gt;JUnit output was the most useful format in practice. GitHub, GitLab and Jenkins all render it, so accessibility failures show up next to unit test failures instead of in a separate HTML file nobody opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the docs honest
&lt;/h2&gt;

&lt;p&gt;The README says 115 rules, 36 ESLint checks and 148 overlay checks. Numbers like that go stale the moment someone adds a rule and forgets the docs.&lt;/p&gt;

&lt;p&gt;So the counts live in one object in &lt;code&gt;core&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RU_A11Y_TOOLKIT_STATS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;canonicalRules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;115&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;eslintChecks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;overlayChecks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;148&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;axe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;104&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;as const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every package has a test that compares its real rule count with this object. If the numbers drift, CI fails. It's a small thing, but it means the published numbers can be trusted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visually impaired mode
&lt;/h2&gt;

&lt;p&gt;The last package is for site visitors, not developers. It's a ready-made settings panel plus hooks for building your own: font size, line height and letter spacing, color schemes (white on black, blue on yellow, and so on), link highlighting, grayscale or hidden images, disabled animations and a thick focus outline.&lt;/p&gt;

&lt;p&gt;It works in two layers. Each setting is applied separately as a &lt;code&gt;data-&lt;/code&gt; attribute on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, and a "full mode" class adds a preset on top. Full mode never downgrades a stronger choice: if the visitor picked 150% text, turning on full mode keeps 150%. Settings persist in &lt;code&gt;localStorage&lt;/code&gt;, are validated on read (a stale or corrupted value falls back to the default) and stay in sync across open tabs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What automation can't do
&lt;/h2&gt;

&lt;p&gt;Automated checks find a large share of accessibility problems, but not all of them. Keyboard flows, screen reader announcements and whether an alt text actually describes the image still need a human. The toolkit doesn't replace manual testing. It makes sure that by the time a person tests, the obvious issues are already gone, and every remaining one is reported in terms the auditor will recognize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Even if you never build for the Russian market, the pattern carries over to EN 301 549, Section 508 or any internal accessibility policy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep rule metadata in one catalog.&lt;/strong&gt; Lint messages, runtime warnings and CI reports should all come from the same source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap good tools instead of forking them.&lt;/strong&gt; A proxy context is enough to localize or enrich another plugin's output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check at three stages:&lt;/strong&gt; static in the editor, runtime in development, end-to-end in CI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make CI distinguish "failed the audit" from "couldn't run the audit".&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test your documentation numbers.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is MIT-licensed and on GitHub: &lt;a href="https://github.com/biondohod/ru-a11y" rel="noopener noreferrer"&gt;biondohod/ru-a11y&lt;/a&gt;. To try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; ru-a11y-toolkit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've built compliance tooling for other regional standards, I'd like to hear how you approached it. Issues and PRs are welcome.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>react</category>
      <category>frontend</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
