Blog

Migrating 1,100 files to Redux Toolkit v2 without freezing the Kibana monorepo

Kibana gave Redux Toolkit v2 the default package name and pushed v1 onto an explicit alias, which inverts the usual migration order. Webpack externals, yarn resolutions and an ESLint rule keep React Redux v7 and v9 out of each other's way.

Test Elastic's leading-edge, out-of-the-box capabilities. Dive into our sample notebooks in the Elasticsearch Labs repo, start a free cloud trial, or try Elastic on your local machine now.

We moved roughly 1,100 files in the Kibana monorepo onto Redux Toolkit (RTK) v2 aliases without asking a single plugin team to pause feature work. The usual migration pattern runs the other way around. Default package names (@reduxjs/toolkit, react-redux, redux) now resolve to v2, and existing v1 code sits behind explicit aliases, like redux-toolkit-v1 and react-redux-v7. Both versions live in node_modules at once, kept apart at runtime by npm aliases and webpack module replacement. An ESLint rule scoped to 36 plugin paths catches anything that tries to cross. When a team is ready, it deletes its path from that list and switches back to the default imports, and the teams around it carry on shipping.

Why upgrade to Redux Toolkit v2?

RTK v2 was released in late 2023. That's nearly three years of running on a major version behind in one of the most widely used state management libraries in the JavaScript ecosystem. It reflects how hard this upgrade is in a codebase of Kibana's size. A previous attempt tried the big-bang approach and stalled when the real scope became clearer. 

So what does v2 actually bring? It ships alongside Redux core 5.0, React-Redux 9.0, Reselect 5.0, and Redux Thunk 3.0. React-Redux 9.0 requires React 18 and drops the useSyncExternalStore shim that v8 carried for React 16/17. Since Kibana already runs React 18, upgrading sheds legacy compatibility code and keeps Kibana on the actively maintained Redux majors.

RTK v2 also brings genuinely useful new features, including inline selectors in createSlice and opt-in inline async thunks through a customized buildCreateSlice setup, along with a combineSlices API with slice reducer injection for code splitting. That last one is particularly interesting for Kibana's plugin architecture where lazy-loading is the norm.

How Redux is used across the Kibana monorepo

Before diving into the solution, it's worth understanding just how varied Redux usage is across Kibana. A full audit of the codebase (tracked in #239863) revealed several distinct camps:

Pattern

Plugins and packages

What the migration needs

Redux Toolkit v1

Discover, Lens, Synthetics, Security Solution

Full v1 to v2 migration

Plain Redux v4

Canvas, Maps, Index Management, Cross-Cluster Replication

redux-v4 alias only, no RTK migration

Kea

Enterprise Search (150+ files), Content Connectors

react-redux-v7 alias, no RTK migration

redux-saga

Synthetics, Graph, Uptime

Store setup only, saga is version-independent

typescript-fsa

Security Solution data-table package

Out of scope

Types and single imports

Expressions, Monitoring

Alias swap only

Plugins such as Discover, Lens, Synthetics, and Security Solution use RTK v1 APIs, including createSlice, configureStore, createAsyncThunk, and createSelector. These are the ones that actually need the v1 to v2 migration. But even here, complexity varies wildly. Lens uses stand-alone getDefaultMiddleware (removed in v2) and PreloadedState (also removed). Security Solution is the largest consumer at 300+ files, mixing modern RTK with legacy plain Redux patterns.

Canvas, Maps, Index Management, Cross-Cluster Replication, and several others still use plain Redux v4 via createStore, combineReducers, applyMiddleware, and connect, which are classic patterns from the pre-RTK era. These don't need RTK migration at all since they're not using it  in the first place, but they do need the redux-v4 alias since the default redux package is now v5.

Enterprise Search and Content Connectors use kea, a Redux abstraction layer with its own logic builders (kea(), useValues, useActions). There are more than 150 files in Enterprise Search alone. RTK migration isn't applicable here, since kea is its own world. But it does depend on react-redux v7 under the hood, which is where the bundler tricks come in.

Synthetics, Graph, and Uptime use redux-saga for side effects. Saga integration is actually independent of the RTK version, but these plugins need their store setup migrated.

The Security Solution data-table package uses typescript-fsa and typescript-fsa-reducers instead of RTK entirely, with its reducer embedded into Security Solution's main store, and isn’t part of the RTK migration at all.

The Expressions plugin only imports shallowEqual from react-redux, and Monitoring only imports types. These just need an alias swap.

Asking every team to migrate simultaneously was a nonstarter. The breaking changes in RTK v2 include stricter type checking and removed APIs, like enableES5() from immer, getDefaultMiddleware and PreloadedState gone entirely, AnyAction replaced by UnknownAction, and behavioral changes in how middleware is configured.

Running Redux Toolkit v1 and v2 side by side

The solution was to flip the typical migration pattern on its head. Instead of keeping the default imports on v1 and introducing v2 under aliases, the default package names (for example, @reduxjs/toolkit, react-redux, and redux) now point to v2. The old versions live under versioned aliases:

  • redux-toolkit-v1

  • react-redux-v7

  • redux-v4

  • immer-v9

  • reselect-v4

  • redux-thunk-v2

{
"@reduxjs/toolkit": "2.12.0",
"redux-toolkit-v1": "npm:@reduxjs/toolkit@1.9.7",
"react-redux": "9.2.0",
"react-redux-v7": "npm:react-redux@7.2.8"
}

This is npm's alias syntax. "react-redux-v7": "npm:react-redux@7.2.8" installs the old version under a different name. Both versions coexist in node_modules without conflicts.

The insight here is that all existing code in this pull request (PR) was moved to v1 aliases. Every import { useSelector } from 'react-redux' became import { useSelector } from 'react-redux-v7'. That's ~1,100 files touched, but the vast majority (~1,000) are mechanical one-liner import swaps. When a team is ready to migrate to v2, they switch back to the default import names. Once all v1 aliases disappear from the codebase, the old packages can be removed entirely.

This avoids the alternative, where v2 imports would end up under nonstandard names permanently, leaving nonstandard imports in the codebase for the long term.

Serving both versions through the bundler

Getting two versions of the same library to coexist at runtime is where things got interesting. Kibana uses kbn-ui-shared-deps-npm to bundle common dependencies as shared webpack externals. This needed to serve both the new v2 packages and the v1 aliases so that both are available at runtime.

Pinning @elastic/charts with yarn resolutions

Then there's @elastic/charts. It depends on RTK v1 internally and can't just be upgraded independently since it's an upstream package. Yarn resolutions pin its nested dependencies to v1 versions:

{
"@elastic/charts/@reduxjs/toolkit": "npm:@reduxjs/toolkit@1.9.7"
}

A NormalModuleReplacementPlugin in the shared deps webpack config detects when an import of immer, @reduxjs/toolkit, redux, react-redux, or reselect originates from within @elastic/charts and redirects resolution to the nested v1 copies. This ensures that @elastic/charts resolves to its compatible v1 dependency set.

Keeping Kea on React Redux v7 with webpack externals

The kea library was another fun case. It declares react-redux as a peer dependency (>= 7), so without special handling its imports resolve to Kibana's default v9 package. The migration keeps Kea consumers on react-redux-v7, so Kea must use that same React context. The fix uses function-based webpack/rspack externals that skip externalizing react-redux when the import comes from node_modules/kea, combined with a NormalModuleReplacementPlugin that rewrites it to react-redux-v7. This ensures that kea uses the v7 React context that matches the <Provider> wrapping its consumers.

Both the webpack (kbn-optimizer) and the rspack (kbn-rspack-optimizer) configs needed these changes, with a shared isKeaReactReduxImport helper extracted to keep the logic consistent.

Using an ESLint rule to prevent cross-version imports

With two versions available, accidental cross-version imports are the biggest risk. A new @kbn/imports/no_redux_toolkit_v2_imports ESLint rule catches any import of the v2 default packages (such as @reduxjs/toolkit, react-redux, or redux, among others) in code that hasn't been migrated yet. It even auto-fixes them to the v1 aliases for file imports and Jest mock paths.

The rule is scoped via an override in .eslintrc.js to the ~36 plugin and package paths currently using v1. When a team migrates, they simply remove their path from the override list. This clean, self-service approach requires no coordination.

// .eslintrc.js (simplified)
overrides: [{
  files: [
'src/platform/plugins/shared/discover/**/*.{ts,tsx}',
'src/platform/plugins/shared/workflows_management/**/*.{ts,tsx}',
// ... 34 more paths
],
  rules: {
'@kbn/imports/no_redux_toolkit_v2_imports': 'error',
  },
}]

Why mixing React Redux v7 and v9 breaks the context

This is worth calling out because it's an easy failure mode to miss during an upgrade. react-redux v9 and v7 create separate React contexts. If a component tree has a v9 <Provider> at the top but a child component calls useSelector from v7 (or vice versa), React-Redux cannot find the matching context. In development, it throws an error explaining that the component must be wrapped in a matching <Provider>; in production, the missing context causes a runtime error when the hook accesses the store.

Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider>

This means that each plugin needs to be explicitly pinned to one version. Shared packages that use react-redux can only be consumed by code on the same version, since mixing isn't possible. This is a constraint that makes the migration inherently per plugin rather than per file.

Migration batches: What can move independently

The dual-version setup gives every team a clear path forward, and the dependency graph analysis from the tracking issue identified natural migration batches:

  • Batch 1: Independent, self-contained stores. Packages like kbn-coloring, transform, timelines, and expandable-flyout have fully internal Redux stores with no types leaking through their public APIs. These can be migrated independently by their owning teams, with minimal risk.

  • Batch 2: Coupled packages. Some packages share RTK types across boundaries and must migrate together. The machine learning (ML)/artificial intelligence for IT operations (AIOps) chain is one example: @kbn/ml-response-stream exports a streamSlice (a createSlice return value) that @kbn/aiops-log-rate-analysis embeds directly into its configureStore. Migrating one without the other causes type mismatches between v1 and v2 slice types. Similar coupling exists across the Lens ecosystem. The Lens plugin depends on @kbn/coloring (which has its own RTK store), @kbn/lens-embeddable-utils, and @kbn/lens-common, while itself being consumed by 40+ packages and plugins across chart expressions, visualizations, Maps, Canvas, and observability plugins. Whether Redux types leak through a package's public API determines if it can be migrated independently or needs coordination. kbn-coloring's store is internal to its React components so it's safe to migrate alone, but other coupling points need careful analysis.

  • Batch 3+: The big ones. Discover, Security Solution, and Lens each have their own migration timelines. Security Solution's 300+ files and mix of RTK with plain Redux v4 and typescript-fsa make it the largest effort, but the different patterns can be addressed independently. Lens has the trickiest v2 breaking changes around middleware configuration; stand-alone getDefaultMiddleware and PreloadedState are both removed in v2, and it has four custom middleware files with complex typing.

Beyond the batched migrations:

  • Deprecated features can stay on v1 aliases. When the feature is removed, the v1 imports disappear through code deletion, without any migration work.

  • Plain Redux v4 plugins (Canvas, Maps, and others) are entirely out of scope for RTK migration. They'd benefit from modernization, but that's a separate initiative.

  • Kea plugins need react-redux-v7 to react-redux alias updates eventually, but no RTK migration. The longer-term question (whether to keep Kea or migrate to RTK v2) is a separate decision.

  • The dual-version approach adds measurable bundle overhead during the transition. It’s a trade-off but is acceptable for the migration period.

Lessons for other large monorepo upgrades

The ESLint rule turned out to be the linchpin. Without automated enforcement, aliased imports would drift back to default names within weeks. With it, the migration state is visible in the paths listed in the override. As of the initial PR, zero files import from @reduxjs/toolkit v2. Every RTK usage goes through the redux-toolkit-v1 alias. That's the starting line.

The preparation work also reached beyond import paths. Jest mocks referencing react-redux needed updating to react-redux-v7, as did Storybook previews, test helpers, and ambient type declarations. Multiple rounds of node scripts/eslint_all_files --no-cache --fix caught the mechanical cases; the remaining cases needed manual fixes.

If you're facing a similar major dependency upgrade in a large monorepo, the pattern of giving the new version the default name and the old version an explicit alias is worth considering. New code naturally uses the current version, while older usage stays visible and trackable until it reaches zero.

Related Content

Taming PUNKs: How ES|QL queries Elasticsearch fields it was never told about

Alexander Spies

ES95: Adaptive Compression for Elasticsearch Time-Series Metrics

Salvatore Campagna

How Elasticsearch's batched query phase improves search performance at scale

Ben Chaplin

Why Elasticsearch is becoming a columnar database

Yannis Roussos

Your compliance posture just got an upgrade: Elasticsearch now supports FIPS 140-3

Fabio Busatto