# Validate what players type

> Check text and number input with validators, show errors under the field, and enable a Save button only while every field is valid.

URL: https://xichil.dev/docs/xis-mvui/v1/guides/validation/ · Updated: 2026-11-06 · Applies to: Fabric, Quilt, Legacy Fabric, Forge, NeoForge, MC 1.7.10 - 26.3

Four widgets take typed input: [TextInput](https://xichil.dev/docs/xis-mvui/v1/widgets/text-input/index.md), [NumberField](https://xichil.dev/docs/xis-mvui/v1/widgets/number-field/index.md),
[MultiLineInput](https://xichil.dev/docs/xis-mvui/v1/widgets/multi-line-input/index.md) and the input of a
[StringListEditor](https://xichil.dev/docs/xis-mvui/v1/widgets/string-list-editor/index.md). They all validate the same way.

## How validation works

- A **validator** is a function from the input to an error `Text`, or `null` when the input is valid.
- It runs after every edit and on every refresh, on the main thread. Keep it fast.
- The error shows on a line below the field.
- `onChange` receives the input **only while it is valid**, so your config never holds invalid text.
- The typed text lives in the widget, not in your code. Invalid text stays in the field across refreshes until
  the player fixes it.

## Validate a text field

```java
TextInput name = TextInput.builder("name")
        .label(Text.translatable("mymod.name", "Profile name"))
        .initial(config.profileName)
        .maxLength(32)
        .validator(s -> s.trim().isEmpty()
                ? Text.translatable("mymod.name.empty", "Enter a name")
                : null)
        .onChange(s -> config.profileName = s)
        .build();
```

`maxLength` is a hard limit: the field accepts no more characters, so you don't need to validate length.

## Validate a number

`NumberField` checks the number itself. It shows these errors with no validator at all:

| Input | Error |
| --- | --- |
| not a number, or empty | "Not a number" |
| a decimal in an `ofInt` field (`2.5`; `2.0` is accepted as 2) | "Not a whole number" |
| below `range`'s minimum | "Must be at least %s" |
| above `range`'s maximum | "Must be at most %s" |

Your own validator runs last, only on numbers that passed those checks:

```java
NumberField<Integer> port = NumberField.ofInt("port")
        .label(Text.translatable("mymod.port", "Port"))
        .initial(config.port)
        .range(1024, 65535)
        .validator(n -> n == 8080 ? Text.translatable("mymod.port.taken", "8080 is reserved") : null)
        .onChange(n -> config.port = n)
        .build();
```

Numbers are parsed with `.` as the decimal separator in every language, and `1e3` is accepted.

## Enable Save only while the input is valid

Keep a reference to the field and read `isValid()` from the button's `enabled` supplier. A disabled button
shows its tooltip too, which is a good place to say why:

```java
Button create = Button.builder("create")
        .label(Text.translatable("mymod.create", "Create"))
        .enabled(name::isValid)
        .tooltip(() -> name.isValid() ? null : Text.translatable("mymod.create.fix", "Fix the name first"))
        .onPress(() -> {
            store.create(name.value());
            host.back();
        })
        .build();
```

With several fields, combine them: `.enabled(() -> name.isValid() && port.input().isValid())`.
`NumberField.input()` is the text field it is drawn as.

## Read the input in an action

Instead of mirroring every edit with `onChange`, an action can read the field when it runs:

| Widget | Current input | Valid? |
| --- | --- | --- |
| `TextInput` | `value()`, valid or not | `isValid()`, `error()` |
| `MultiLineInput` | `value()`, with `\n` line breaks | `isValid()`, `error()` |
| `NumberField` | `number()` | `input().isValid()` |

To replace the text from code, for example to clear a field after using it, call `setValue("")` on a
`TextInput` or `MultiLineInput`.

## Validate list items

A `StringListEditor` already refuses empty input and items that are in the list ("Already in the list"). Add
your own rule with `validator`:

```java
StringListEditor.builder("servers")
        .items(() -> config.servers)
        .onAdd(config.servers::add)
        .onRemove(config.servers::remove)
        .inputLabel(Text.translatable("mymod.servers.add", "Server address"))
        .validator(s -> s.contains(" ") ? Text.translatable("mymod.servers.space", "No spaces") : null)
        .build();
```

## Test it

`FakeHost.type(id, text)` types into a field; `render()` shows the error:

```java
host.type("port", "80");
assertTrue(host.render().contains("(error: Must be at least 1024)"));
```

## Next steps

- [TextInput](https://xichil.dev/docs/xis-mvui/v1/widgets/text-input/index.md) and [NumberField](https://xichil.dev/docs/xis-mvui/v1/widgets/number-field/index.md): every builder option.
- [State and refresh](https://xichil.dev/docs/xis-mvui/v1/concepts/state-and-refresh/index.md): why typed text is the exception to "the mod owns the value".
